refactor: unify server lifecycle, migrate gRPC client, and clean up sandbox v2
Server lifecycle: - Introduce service.Service to manage HTTP + gRPC startup/shutdown - Fix gRPC mutex deadlock in StartServer when port is occupied - Add GracefulStop with 5s timeout before forced Stop in grpc.go - Pre-check HTTP and gRPC port availability in cmd/start.go - Print gRPC server address in startup access-points block gRPC client refactor: - Move token manager and client from tai/grpc/ to grpc/client/ - Add backward-compatible aliases in tai/yao.go and tai/token.go - Update cmd/run.go to import grpc/client directly (no tai dependency) Sandbox v2 docker migration: - Delete sandbox/v2/docker/ (moved to tai repo) - Update sandbox/docker/build.sh hint to point to tai repo - Clean up .gitignore entries for removed docker directory - Temporarily disable SandboxV2Test and BenchmarkSandboxV2 in CI (docker images need rebuild after tai repo migration) Tai integration: - Add direct-mode registration API handlers in tai/api/ - Add heartbeat handler and token management wrappers - Update tai/registry and tai/tunnel for latest protocol - Replace yao-grpc references with tai call in docs Made-with: Cursor
This commit is contained in:
parent
f6b917afe1
commit
83ebe49036
44 changed files with 979 additions and 2762 deletions
18
.github/workflows/pr-test.yml
vendored
18
.github/workflows/pr-test.yml
vendored
|
|
@ -925,9 +925,11 @@ jobs:
|
|||
|
||||
# =============================================================================
|
||||
# Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d)
|
||||
# TEMPORARILY DISABLED: docker images need rebuild after tai repo migration
|
||||
# =============================================================================
|
||||
SandboxV2Test:
|
||||
runs-on: ubuntu-latest
|
||||
if: false # temporarily disabled – restore after sandbox docker images are rebuilt
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:6.0
|
||||
|
|
@ -941,9 +943,6 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
go: ["1.25"]
|
||||
if: >
|
||||
${{ github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: "Download artifact"
|
||||
uses: actions/github-script@v7
|
||||
|
|
@ -1072,7 +1071,7 @@ jobs:
|
|||
|
||||
- name: Pull Test Images
|
||||
run: |
|
||||
docker pull yaoapp/sandbox-v2-test:latest || true
|
||||
docker pull yaoapp/tai-sandbox-test:latest || true
|
||||
docker pull yaoapp/tai:latest
|
||||
docker pull alpine:latest
|
||||
|
||||
|
|
@ -1176,7 +1175,7 @@ jobs:
|
|||
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
|
||||
TAI_TEST_HOST_IP: "172.17.0.1"
|
||||
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
|
||||
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
|
||||
SANDBOX_TEST_IMAGE: "yaoapp/tai-sandbox-test:latest"
|
||||
run: make unit-test-sandbox-v2
|
||||
|
||||
- name: Codecov Report
|
||||
|
|
@ -1811,15 +1810,14 @@ jobs:
|
|||
|
||||
# =============================================================================
|
||||
# Benchmark: Sandbox V2 + Workspace (parallel with SandboxV2Test, non-blocking)
|
||||
# TEMPORARILY DISABLED: docker images need rebuild after tai repo migration
|
||||
# =============================================================================
|
||||
BenchmarkSandboxV2:
|
||||
runs-on: ubuntu-latest
|
||||
if: false # temporarily disabled – restore after sandbox docker images are rebuilt
|
||||
strategy:
|
||||
matrix:
|
||||
go: ["1.25"]
|
||||
if: >
|
||||
${{ github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: "Download artifact"
|
||||
uses: actions/github-script@v7
|
||||
|
|
@ -1934,7 +1932,7 @@ jobs:
|
|||
|
||||
- name: Pull Test Images
|
||||
run: |
|
||||
docker pull yaoapp/sandbox-v2-test:latest || true
|
||||
docker pull yaoapp/tai-sandbox-test:latest || true
|
||||
docker pull yaoapp/tai:latest
|
||||
docker pull alpine:latest
|
||||
|
||||
|
|
@ -2028,7 +2026,7 @@ jobs:
|
|||
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
|
||||
TAI_TEST_HOST_IP: "172.17.0.1"
|
||||
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
|
||||
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
|
||||
SANDBOX_TEST_IMAGE: "yaoapp/tai-sandbox-test:latest"
|
||||
run: make benchmark-sandbox-v2
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
4
.github/workflows/unit-test.yml
vendored
4
.github/workflows/unit-test.yml
vendored
|
|
@ -780,7 +780,7 @@ jobs:
|
|||
|
||||
- name: Pull Test Images
|
||||
run: |
|
||||
docker pull yaoapp/sandbox-v2-test:latest || true
|
||||
docker pull yaoapp/tai-sandbox-test:latest || true
|
||||
docker pull yaoapp/tai:latest
|
||||
docker pull alpine:latest
|
||||
|
||||
|
|
@ -1440,7 +1440,7 @@ jobs:
|
|||
|
||||
- name: Pull Test Images
|
||||
run: |
|
||||
docker pull yaoapp/sandbox-v2-test:latest || true
|
||||
docker pull yaoapp/tai-sandbox-test:latest || true
|
||||
docker pull yaoapp/tai:latest
|
||||
docker pull alpine:latest
|
||||
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -74,8 +74,4 @@ tg-login
|
|||
tg-send
|
||||
registry/data/
|
||||
registry/manager/DESIGN*.md
|
||||
tai/testdata/
|
||||
sandbox/v2/docker/base/*-amd64
|
||||
sandbox/v2/docker/base/*-arm64
|
||||
!sandbox/v2/docker/*.sh
|
||||
!sandbox/v2/docker/*/*.sh
|
||||
tai/testdata/
|
||||
2
Makefile
2
Makefile
|
|
@ -205,7 +205,7 @@ unit-test-registry:
|
|||
# Sandbox V2 Integration Test (tai + sandbox/v2 + workspace)
|
||||
# Requires: Docker, Tai container, optionally k3d for K8s mode
|
||||
# ---------------------------------------------------------------------------
|
||||
SANDBOX_V2_IMAGE ?= yaoapp/sandbox-v2-test:latest
|
||||
SANDBOX_V2_IMAGE ?= yaoapp/tai-sandbox-test:latest
|
||||
|
||||
.PHONY: unit-test-sandbox-v2
|
||||
unit-test-sandbox-v2: unit-test-sandbox-v2-pull unit-test-tai unit-test-sandbox-v2-core unit-test-workspace
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ import (
|
|||
"github.com/yaoapp/kun/exception"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/engine"
|
||||
grpcclient "github.com/yaoapp/yao/grpc/client"
|
||||
ischedule "github.com/yaoapp/yao/schedule"
|
||||
"github.com/yaoapp/yao/share"
|
||||
taigrpc "github.com/yaoapp/yao/tai/grpc"
|
||||
itask "github.com/yaoapp/yao/task"
|
||||
)
|
||||
|
||||
|
|
@ -93,8 +93,8 @@ func runGRPC(cred *Credential, args []string) {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
tm := taigrpc.NewTokenManager(cred.AccessToken, cred.RefreshToken, "", "")
|
||||
client, err := taigrpc.Dial(cred.GRPCAddr, tm)
|
||||
tm := grpcclient.NewTokenManager(cred.AccessToken, cred.RefreshToken, "")
|
||||
client, err := grpcclient.Dial(cred.GRPCAddr, tm)
|
||||
if err != nil {
|
||||
color.Red(" %s %s\n", L("gRPC connect failed:"), err.Error())
|
||||
os.Exit(1)
|
||||
|
|
|
|||
162
cmd/start.go
162
cmd/start.go
|
|
@ -2,9 +2,11 @@ package cmd
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
|
|
@ -16,7 +18,6 @@ import (
|
|||
"github.com/yaoapp/gou/mcp"
|
||||
"github.com/yaoapp/gou/plugin"
|
||||
"github.com/yaoapp/gou/schedule"
|
||||
"github.com/yaoapp/gou/server/http"
|
||||
"github.com/yaoapp/gou/store"
|
||||
"github.com/yaoapp/gou/task"
|
||||
"github.com/yaoapp/gou/websocket"
|
||||
|
|
@ -128,49 +129,24 @@ var startCmd = &cobra.Command{
|
|||
|
||||
// print the messages under the development mode
|
||||
if mode == "development" {
|
||||
|
||||
// Start Studio Server
|
||||
// Yao Studio will be deprecated in the future
|
||||
// go func() {
|
||||
|
||||
// err = studio.Load(config.Conf)
|
||||
// if err != nil {
|
||||
// // fmt.Println(color.RedString(L("Studio Load: %s"), err.Error()))
|
||||
// log.Error("Studio Load: %s", err.Error())
|
||||
// return
|
||||
// }
|
||||
|
||||
// err := studio.Start(config.Conf)
|
||||
// if err != nil {
|
||||
// log.Error("Studio Start: %s", err.Error())
|
||||
// return
|
||||
// }
|
||||
// }()
|
||||
// defer studio.Stop()
|
||||
|
||||
printApis(false)
|
||||
printTasks(false)
|
||||
printSchedules(false)
|
||||
printConnectors(false)
|
||||
printStores(false)
|
||||
printMCPs(false)
|
||||
|
||||
}
|
||||
|
||||
root, _ := adminRoot()
|
||||
endpoints := []setup.Endpoint{{URL: fmt.Sprintf("http://%s%s", "127.0.0.1", port), Interface: "localhost"}}
|
||||
switch host {
|
||||
case "0.0.0.0":
|
||||
// All interfaces
|
||||
if values, err := setup.Endpoints(config.Conf); err == nil {
|
||||
endpoints = append(endpoints, values...)
|
||||
}
|
||||
break
|
||||
case "127.0.0.1":
|
||||
// Localhost only
|
||||
break
|
||||
default:
|
||||
// Filter by the host IP
|
||||
matched := false
|
||||
endpoints = []setup.Endpoint{}
|
||||
if values, err := setup.Endpoints(config.Conf); err == nil {
|
||||
|
|
@ -187,32 +163,6 @@ var startCmd = &cobra.Command{
|
|||
}
|
||||
}
|
||||
|
||||
// Print gRPC listen addresses
|
||||
grpcAddrs := yaogrpc.Addr()
|
||||
for _, addr := range grpcAddrs {
|
||||
fmt.Println(color.WhiteString(L("Listening")), color.GreenString(" %s (gRPC)", addr))
|
||||
}
|
||||
|
||||
fmt.Println(color.WhiteString("\n---------------------------------"))
|
||||
fmt.Println(color.WhiteString(L("Access Points")))
|
||||
fmt.Println(color.WhiteString("---------------------------------"))
|
||||
apiRoot := "/api"
|
||||
if openapi.Server != nil {
|
||||
apiRoot = openapi.Server.Config.BaseURL
|
||||
}
|
||||
for _, endpoint := range endpoints {
|
||||
fmt.Println(color.CyanString("\n%s", endpoint.Interface))
|
||||
fmt.Println(color.WhiteString("--------------------------"))
|
||||
fmt.Println(color.WhiteString(L("Website")), color.GreenString(" %s", endpoint.URL))
|
||||
fmt.Println(color.WhiteString(L("Dashboard")), color.GreenString(" %s/%s/auth/entry", endpoint.URL, strings.Trim(root, "/")))
|
||||
if openapi.Server != nil {
|
||||
fmt.Println(color.WhiteString(L("OpenAPI")), color.GreenString(" %s%s", endpoint.URL, apiRoot))
|
||||
} else {
|
||||
fmt.Println(color.WhiteString(L("API")), color.GreenString(" %s%s", endpoint.URL, apiRoot))
|
||||
}
|
||||
}
|
||||
fmt.Println("")
|
||||
|
||||
// Print welcome message for the new application
|
||||
if isnew {
|
||||
printWelcome()
|
||||
|
|
@ -230,32 +180,66 @@ var startCmd = &cobra.Command{
|
|||
// (must happen before HTTP/gRPC start so handlers can access it)
|
||||
tairegistry.Init(nil)
|
||||
|
||||
// Start HTTP Server
|
||||
srv, err := service.Start(config.Conf)
|
||||
defer func() {
|
||||
service.Stop(srv)
|
||||
fmt.Println(color.GreenString(L("✨Exited successfully!")))
|
||||
}()
|
||||
// Pre-flight: detect port conflicts before attempting to start servers.
|
||||
if occupied, proc := portOccupied(config.Conf.Host, config.Conf.Port); occupied {
|
||||
fmt.Println(color.RedString(L("Fatal: HTTP port %d is already in use%s"), config.Conf.Port, proc))
|
||||
return
|
||||
}
|
||||
if strings.ToLower(config.Conf.GRPC.Enabled) != "off" {
|
||||
for _, h := range strings.Split(config.Conf.GRPC.Host, ",") {
|
||||
if occupied, proc := portOccupied(strings.TrimSpace(h), config.Conf.GRPC.Port); occupied {
|
||||
fmt.Println(color.RedString(L("Fatal: gRPC port %d is already in use%s"), config.Conf.GRPC.Port, proc))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start all servers (gRPC + HTTP) as a single unit.
|
||||
// Start() blocks until HTTP port is bound (READY) or returns error.
|
||||
svc, err := service.Start(config.Conf, service.ServerHooks{
|
||||
Start: yaogrpc.StartServer,
|
||||
Stop: yaogrpc.Stop,
|
||||
Addrs: yaogrpc.Addr,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
// Start gRPC Server (after HTTP, LIFO shutdown: gRPC stops before HTTP)
|
||||
if grpcErr := yaogrpc.StartServer(config.Conf); grpcErr != nil {
|
||||
fmt.Println(color.RedString(L("gRPC: %s"), grpcErr.Error()))
|
||||
os.Exit(1)
|
||||
// Access Points (printed after servers are up so addresses are known)
|
||||
fmt.Println(color.WhiteString("\n---------------------------------"))
|
||||
fmt.Println(color.WhiteString(L("Access Points")))
|
||||
fmt.Println(color.WhiteString("---------------------------------"))
|
||||
|
||||
if grpcAddrs := svc.HookAddrs(); len(grpcAddrs) > 0 {
|
||||
fmt.Println(color.CyanString("\ngRPC"))
|
||||
fmt.Println(color.WhiteString("--------------------------"))
|
||||
for _, addr := range grpcAddrs {
|
||||
fmt.Println(color.WhiteString(L("Server")), color.GreenString(" %s", addr))
|
||||
}
|
||||
}
|
||||
defer yaogrpc.Stop()
|
||||
|
||||
apiRoot := "/api"
|
||||
if openapi.Server != nil {
|
||||
apiRoot = openapi.Server.Config.BaseURL
|
||||
}
|
||||
for _, endpoint := range endpoints {
|
||||
fmt.Println(color.CyanString("\n%s", endpoint.Interface))
|
||||
fmt.Println(color.WhiteString("--------------------------"))
|
||||
fmt.Println(color.WhiteString(L("Website")), color.GreenString(" %s", endpoint.URL))
|
||||
fmt.Println(color.WhiteString(L("Dashboard")), color.GreenString(" %s/%s/auth/entry", endpoint.URL, strings.Trim(root, "/")))
|
||||
if openapi.Server != nil {
|
||||
fmt.Println(color.WhiteString(L("OpenAPI")), color.GreenString(" %s%s", endpoint.URL, apiRoot))
|
||||
} else {
|
||||
fmt.Println(color.WhiteString(L("API")), color.GreenString(" %s%s", endpoint.URL, apiRoot))
|
||||
}
|
||||
}
|
||||
fmt.Println("")
|
||||
|
||||
// Start watching
|
||||
watchDone := make(chan uint8, 1)
|
||||
if mode == "development" && !startDisableWatching {
|
||||
// fmt.Println(color.WhiteString("\n---------------------------------"))
|
||||
// fmt.Println(color.WhiteString(L("Watching")))
|
||||
// fmt.Println(color.WhiteString("---------------------------------"))
|
||||
go service.Watch(srv, watchDone)
|
||||
go svc.Watch(watchDone)
|
||||
}
|
||||
|
||||
// Print the messages under the production mode
|
||||
|
|
@ -279,31 +263,15 @@ var startCmd = &cobra.Command{
|
|||
fmt.Printf("\n")
|
||||
}
|
||||
|
||||
fmt.Println(color.GreenString(L("Server is up and running...")))
|
||||
fmt.Println(color.GreenString("Ctrl+C to stop"))
|
||||
|
||||
for {
|
||||
select {
|
||||
case v := <-srv.Event():
|
||||
|
||||
switch v {
|
||||
case http.READY:
|
||||
fmt.Println(color.GreenString(L("Server is up and running...")))
|
||||
fmt.Println(color.GreenString("Ctrl+C to stop"))
|
||||
break
|
||||
|
||||
case http.CLOSED:
|
||||
fmt.Println(color.GreenString(L("✨Exited successfully!")))
|
||||
watchDone <- 1
|
||||
return
|
||||
|
||||
case http.ERROR:
|
||||
color.Red("Fatal: check the error information in the log")
|
||||
watchDone <- 1
|
||||
return
|
||||
|
||||
default:
|
||||
fmt.Println("Signal:", v)
|
||||
}
|
||||
|
||||
case <-interrupt:
|
||||
fmt.Println(color.WhiteString("\nShutting down..."))
|
||||
svc.Stop()
|
||||
fmt.Println(color.GreenString(L("✨Exited successfully!")))
|
||||
watchDone <- 1
|
||||
return
|
||||
}
|
||||
|
|
@ -399,7 +367,7 @@ func printStores(silent bool) {
|
|||
}
|
||||
|
||||
fmt.Println(color.WhiteString("\n---------------------------------"))
|
||||
fmt.Println(color.WhiteString(L("Stores List (%d)"), len(connector.Connectors)))
|
||||
fmt.Println(color.WhiteString(L("Stores List (%d)"), len(store.Pools)))
|
||||
fmt.Println(color.WhiteString("---------------------------------"))
|
||||
for name := range store.Pools {
|
||||
fmt.Print(color.CyanString("[Store]"))
|
||||
|
|
@ -647,6 +615,18 @@ func colorMehtod(method string) string {
|
|||
}
|
||||
}
|
||||
|
||||
// portOccupied probes whether host:port is already bound.
|
||||
// Returns (true, " (pid XXXX)") when occupied, (false, "") otherwise.
|
||||
func portOccupied(host string, port int) (bool, string) {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return true, fmt.Sprintf(" (%s)", err.Error())
|
||||
}
|
||||
ln.Close()
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
startCmd.PersistentFlags().BoolVarP(&startDebug, "debug", "", false, L("Development mode"))
|
||||
startCmd.PersistentFlags().BoolVarP(&startDisableWatching, "disable-watching", "", false, L("Disable watching"))
|
||||
|
|
|
|||
13
grpc/IMPL.md
13
grpc/IMPL.md
|
|
@ -40,12 +40,11 @@ grpc/
|
|||
Container client:
|
||||
|
||||
```
|
||||
tai/grpc/
|
||||
├── grpc.go // gRPC client, Dial, method wrappers
|
||||
├── auth.go // read env tokens, attach metadata, handle refresh
|
||||
├── grpc_test.go
|
||||
└── cmd/
|
||||
└── main.go // yao-grpc binary entry
|
||||
grpc/client/ // gRPC client (moved from tai/grpc/ to grpc/client/)
|
||||
├── client.go // gRPC client, Dial, method wrappers
|
||||
└── token.go // read env tokens, attach metadata, handle refresh
|
||||
|
||||
tai repo: tai/call/ // container-side binary (replaces yao-grpc)
|
||||
```
|
||||
|
||||
## V1 Phases
|
||||
|
|
@ -163,7 +162,7 @@ Deliverable: LLM (unary + stream) and Agent streaming via gRPC.
|
|||
|
||||
### Phase 4: Tai gateway change (Tai repo) ✅
|
||||
|
||||
Depends on: Phase 1 (need proto definitions for testing). yao-grpc depends on this.
|
||||
Depends on: Phase 1 (need proto definitions for testing). `tai call` (tai repo) depends on this.
|
||||
|
||||
Tai gateway currently dials a fixed `YaoUpstream` at startup. New behavior: yao-grpc tells Tai where to forward via request metadata (`x-grpc-upstream`). Tai reads the target address and proxies to it — removes `YaoUpstream` startup config.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package grpc
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -12,15 +12,14 @@ import (
|
|||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// Client wraps a gRPC connection to a Yao server (direct or via Tai relay).
|
||||
// TokenManager handles auth metadata attachment and token refresh automatically.
|
||||
// Client wraps a gRPC connection to a Yao server.
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
svc pb.YaoClient
|
||||
token *TokenManager
|
||||
}
|
||||
|
||||
// NewFromEnv reads YAO_GRPC_ADDR (required) and token env vars, dials the
|
||||
// NewFromEnv reads YAO_GRPC_ADDR and token env vars, dials the
|
||||
// gRPC server, and returns a connected Client.
|
||||
func NewFromEnv() (*Client, error) {
|
||||
addr := os.Getenv("YAO_GRPC_ADDR")
|
||||
|
|
@ -36,10 +35,7 @@ func NewFromEnv() (*Client, error) {
|
|||
return Dial(addr, tm)
|
||||
}
|
||||
|
||||
// Dial connects to the gRPC server at addr with the given TokenManager.
|
||||
// Bare host:port addresses are wrapped with passthrough:/// for grpc.NewClient
|
||||
// compatibility (grpc.NewClient defaults to dns scheme which may fail for hostnames
|
||||
// like host.docker.internal).
|
||||
// Dial connects to a Yao gRPC server at addr with the given TokenManager.
|
||||
func Dial(addr string, tm *TokenManager) (*Client, error) {
|
||||
opts := []grpc.DialOption{
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
|
|
@ -180,7 +176,6 @@ func (c *Client) ChatCompletions(ctx context.Context, connector string, messages
|
|||
}
|
||||
|
||||
// ChatCompletionsStream sends a streaming chat completion request.
|
||||
// The callback receives each chunk's data; return a non-nil error to stop.
|
||||
func (c *Client) ChatCompletionsStream(ctx context.Context, connector string, messages, options []byte, cb func(data []byte, done bool) error) error {
|
||||
stream, err := c.svc.ChatCompletionsStream(ctx, &pb.ChatRequest{
|
||||
Connector: connector,
|
||||
|
|
@ -210,7 +205,6 @@ func (c *Client) ChatCompletionsStream(ctx context.Context, connector string, me
|
|||
// --- Agent ---
|
||||
|
||||
// AgentStream calls an agent with streaming response.
|
||||
// The callback receives each chunk's data; return a non-nil error to stop.
|
||||
func (c *Client) AgentStream(ctx context.Context, assistantID string, messages, options []byte, cb func(data []byte, done bool) error) error {
|
||||
stream, err := c.svc.AgentStream(ctx, &pb.AgentRequest{
|
||||
AssistantId: assistantID,
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
package grpc
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
|
|
@ -10,46 +9,30 @@ import (
|
|||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// TokenManager reads auth credentials from environment variables and attaches
|
||||
// them as gRPC metadata on every call. It also handles automatic token refresh
|
||||
// by reading new tokens from response headers.
|
||||
// TokenManager attaches auth credentials as gRPC metadata on every call
|
||||
// and handles automatic token refresh from response headers.
|
||||
type TokenManager struct {
|
||||
mu sync.RWMutex
|
||||
accessToken string
|
||||
refreshToken string
|
||||
sandboxID string
|
||||
upstream string // only set when YAO_GRPC_TAI=enable
|
||||
taiMode bool
|
||||
}
|
||||
|
||||
// NewTokenManagerFromEnv creates a TokenManager from environment variables.
|
||||
// Returns an error if required variables are missing.
|
||||
func NewTokenManagerFromEnv() (*TokenManager, error) {
|
||||
tm := &TokenManager{
|
||||
return &TokenManager{
|
||||
accessToken: os.Getenv("YAO_TOKEN"),
|
||||
refreshToken: os.Getenv("YAO_REFRESH_TOKEN"),
|
||||
sandboxID: os.Getenv("YAO_SANDBOX_ID"),
|
||||
}
|
||||
|
||||
if os.Getenv("YAO_GRPC_TAI") == "enable" {
|
||||
tm.taiMode = true
|
||||
tm.upstream = os.Getenv("YAO_GRPC_UPSTREAM")
|
||||
if tm.upstream == "" {
|
||||
return nil, fmt.Errorf("YAO_GRPC_TAI=enable but YAO_GRPC_UPSTREAM is not set")
|
||||
}
|
||||
}
|
||||
|
||||
return tm, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewTokenManager creates a TokenManager with explicit values (for testing).
|
||||
func NewTokenManager(accessToken, refreshToken, sandboxID, upstream string) *TokenManager {
|
||||
// NewTokenManager creates a TokenManager with explicit values.
|
||||
func NewTokenManager(accessToken, refreshToken, sandboxID string) *TokenManager {
|
||||
return &TokenManager{
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
sandboxID: sandboxID,
|
||||
upstream: upstream,
|
||||
taiMode: upstream != "",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +41,7 @@ func (tm *TokenManager) AttachMetadata(ctx context.Context) context.Context {
|
|||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
pairs := []string{}
|
||||
var pairs []string
|
||||
if tm.accessToken != "" {
|
||||
pairs = append(pairs, "authorization", "Bearer "+tm.accessToken)
|
||||
}
|
||||
|
|
@ -68,9 +51,6 @@ func (tm *TokenManager) AttachMetadata(ctx context.Context) context.Context {
|
|||
if tm.sandboxID != "" {
|
||||
pairs = append(pairs, "x-sandbox-id", tm.sandboxID)
|
||||
}
|
||||
if tm.taiMode && tm.upstream != "" {
|
||||
pairs = append(pairs, "x-grpc-upstream", tm.upstream)
|
||||
}
|
||||
|
||||
if len(pairs) == 0 {
|
||||
return ctx
|
||||
|
|
@ -79,7 +59,7 @@ func (tm *TokenManager) AttachMetadata(ctx context.Context) context.Context {
|
|||
}
|
||||
|
||||
// HandleResponseHeaders reads new tokens from response headers and updates
|
||||
// the in-memory credentials. Call after each gRPC response.
|
||||
// the in-memory credentials.
|
||||
func (tm *TokenManager) HandleResponseHeaders(header metadata.MD) {
|
||||
if header == nil {
|
||||
return
|
||||
|
|
@ -103,10 +83,8 @@ func (tm *TokenManager) UnaryInterceptor() grpc.UnaryClientInterceptor {
|
|||
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
|
||||
ctx = tm.AttachMetadata(ctx)
|
||||
|
||||
var header metadata.MD
|
||||
opts = append(opts, grpc.Header(&header))
|
||||
|
||||
err := invoker(ctx, method, req, reply, cc, opts...)
|
||||
tm.HandleResponseHeaders(header)
|
||||
return err
|
||||
|
|
@ -114,8 +92,7 @@ func (tm *TokenManager) UnaryInterceptor() grpc.UnaryClientInterceptor {
|
|||
}
|
||||
|
||||
// StreamInterceptor returns a gRPC stream client interceptor that attaches
|
||||
// auth metadata. Token refresh from stream headers is handled by the caller
|
||||
// via stream.Header().
|
||||
// auth metadata.
|
||||
func (tm *TokenManager) StreamInterceptor() grpc.StreamClientInterceptor {
|
||||
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn,
|
||||
method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
|
|
@ -125,23 +102,16 @@ func (tm *TokenManager) StreamInterceptor() grpc.StreamClientInterceptor {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if header, hErr := stream.Header(); hErr == nil {
|
||||
tm.HandleResponseHeaders(header)
|
||||
}
|
||||
|
||||
return stream, nil
|
||||
}
|
||||
}
|
||||
|
||||
// AccessToken returns the current access token (for testing/debugging).
|
||||
// AccessToken returns the current access token.
|
||||
func (tm *TokenManager) AccessToken() string {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
return tm.accessToken
|
||||
}
|
||||
|
||||
// IsTaiMode returns whether the client is configured for Tai relay mode.
|
||||
func (tm *TokenManager) IsTaiMode() bool {
|
||||
return tm.taiMode
|
||||
}
|
||||
41
grpc/grpc.go
41
grpc/grpc.go
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
|
|
@ -162,7 +163,7 @@ func StartServer(cfg config.Config) error {
|
|||
addr := net.JoinHostPort(strings.TrimSpace(h), port)
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
Stop()
|
||||
stopLocked()
|
||||
return err
|
||||
}
|
||||
listeners = append(listeners, lis)
|
||||
|
|
@ -179,17 +180,39 @@ func StartServer(cfg config.Config) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the gRPC server. Safe to call if server was never started.
|
||||
// stopLocked performs cleanup while the caller already holds mu.
|
||||
func stopLocked() {
|
||||
s := server
|
||||
server = nil
|
||||
listeners = nil
|
||||
addrs = nil
|
||||
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.GracefulStop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
log.Info("gRPC server stopped gracefully")
|
||||
case <-time.After(5 * time.Second):
|
||||
log.Warn("gRPC server graceful stop timed out, forcing stop")
|
||||
s.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gracefully stops the gRPC server with a 5-second timeout.
|
||||
// If GracefulStop doesn't complete in time (e.g. active streams), it forces Stop.
|
||||
// Safe to call if server was never started.
|
||||
func Stop() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if server != nil {
|
||||
server.GracefulStop()
|
||||
server = nil
|
||||
}
|
||||
listeners = nil
|
||||
addrs = nil
|
||||
stopLocked()
|
||||
}
|
||||
|
||||
// GRPCServer returns the active gRPC server instance.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import (
|
|||
"github.com/yaoapp/yao/openapi/team"
|
||||
openapiTrace "github.com/yaoapp/yao/openapi/trace"
|
||||
"github.com/yaoapp/yao/openapi/user"
|
||||
taiapi "github.com/yaoapp/yao/tai/api"
|
||||
taitunnel "github.com/yaoapp/yao/tai/tunnel"
|
||||
)
|
||||
|
||||
|
|
@ -182,6 +183,11 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
|
|||
group.Any("/tai/:taiID/proxy/*path", taitunnel.HandleProxy)
|
||||
group.GET("/tai/:taiID/vnc/*path", taitunnel.HandleVNC)
|
||||
|
||||
// Tai direct registration API (uses /tai-nodes/ prefix to avoid routing conflict with /tai/:taiID/)
|
||||
group.POST("/tai-nodes/register", taiapi.HandleRegister)
|
||||
group.POST("/tai-nodes/heartbeat", taiapi.HandleHeartbeat)
|
||||
group.DELETE("/tai-nodes/register/:tai_id", taiapi.HandleUnregister)
|
||||
|
||||
// Custom handlers (Defined by developer)
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,14 +50,14 @@ High-level business layer on top of `tai.Client`. Manages container lifecycle, u
|
|||
General-purpose gRPC gateway exposed by the Yao process. Not limited to sandbox IPC — it exposes process execution, shell, API proxy, MCP, LLM, and Agent capabilities to any gRPC client. 14 RPCs defined; V1 (unary + LLM/Agent streaming) complete, V2 (base streaming via `gou/stream`) pending.
|
||||
|
||||
**Clients:**
|
||||
- Container-internal `yao-grpc` (via Tai Gateway relay or direct)
|
||||
- Container-internal `tai call` (via Tai Gateway relay or direct)
|
||||
- `yao run` CLI (after `yao login`)
|
||||
- Other Yao instances (future node-to-node)
|
||||
|
||||
**IPC path (replacing Unix socket):**
|
||||
```
|
||||
Local: Container → yao-grpc (tai/grpc/) → Yao gRPC 127.0.0.1:9099
|
||||
Remote: Container → yao-grpc (tai/grpc/) → Tai Gateway (:9100 gRPC) → Yao gRPC Server (:9099)
|
||||
Local: Container → tai call (tai repo) → Yao gRPC 127.0.0.1:9099
|
||||
Remote: Container → tai call (tai repo) → Tai Gateway (:9100 gRPC) → Yao gRPC Server (:9099)
|
||||
```
|
||||
|
||||
All modes use gRPC — no Unix socket fallback. `yao-grpc` reads `YAO_GRPC_ADDR` from env and connects. Local containers point directly at the Yao gRPC server on loopback; remote containers point at the Tai relay. Tai does **not** know Yao gRPC address at startup — `yao-grpc` carries target in `x-grpc-upstream` request metadata. This keeps Tai stateless and allows one Tai to serve multiple Yao instances.
|
||||
|
|
|
|||
|
|
@ -165,8 +165,8 @@ case $TOOL in
|
|||
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
|
||||
;;
|
||||
v2)
|
||||
echo "V2 images have their own build script: sandbox/v2/docker/build.sh"
|
||||
echo "Usage: sandbox/v2/docker/build.sh [true|false]"
|
||||
echo "V2 images have moved to the tai repo: tai/docker/sandbox/build.sh"
|
||||
echo "See: https://github.com/yaoapp/tai/tree/main/docker/sandbox"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
|
|
|
|||
|
|
@ -1121,7 +1121,7 @@ Permission control is the responsibility of the caller (JS scripts, Agent hooks,
|
|||
| **Runtime** | Direct Docker SDK | tai.Client pool (Docker/K8s/Remote) |
|
||||
| **Execution** | Exec + Stream | Exec + Stream + Attach (WS/SSE) |
|
||||
| **File I/O** | bind mount + Docker Copy | `workspace.FS` (fs.FS compatible) |
|
||||
| **IPC** | Unix socket + yao-bridge | gRPC (yao-grpc) |
|
||||
| **IPC** | Unix socket + yao-bridge | gRPC (tai call) |
|
||||
| **Idle detection** | External calls only | Dual: external calls + container heartbeat |
|
||||
| **Lifecycle** | Chat session only | Policy-based (oneshot/session/longrunning/persistent) |
|
||||
| **Pool** | Single Docker daemon | Multi-pool with per-pool policies |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
GO ?= go
|
||||
GOFILES := $(shell find . -name "*.go" -not -path "./docker/*")
|
||||
PACKAGES := $(shell $(GO) list ./...)
|
||||
TEST_IMAGE ?= yaoapp/sandbox-v2-test:latest
|
||||
TEST_IMAGE ?= yaoapp/tai-sandbox-test:latest
|
||||
TEST_TIMEOUT ?= 600s
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -589,8 +589,8 @@ sandbox-v2-test:
|
|||
Key decisions:
|
||||
- SQLite only — sandbox is infrastructure, not data-model dependent
|
||||
- Tai container provides remote mode — exercises the full proxy path
|
||||
- `sandbox-v2-test` as default test image — includes `yao-grpc` (heartbeat), `openai-proxy`, Nginx, WS echo + SSE test services
|
||||
- CI builds test image from source (Step 4.5) — ensures binary compatibility with latest tai SDK + yao-grpc changes
|
||||
- `sandbox-v2-test` as default test image — includes `tai` (heartbeat), `openai-proxy`, Nginx, WS echo + SSE test services
|
||||
- CI builds test image from source (Step 4.5) — ensures binary compatibility with latest tai SDK changes
|
||||
- Attach tests (WS/SSE) use `sandbox-v2-test` image's built-in test services
|
||||
|
||||
## Coverage
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ func TestAttachWS(t *testing.T) {
|
|||
|
||||
img := testImage()
|
||||
if img == "alpine:latest" {
|
||||
t.Skip("WebSocket test requires sandbox-v2-test image with ws-echo service")
|
||||
t.Skip("WebSocket test requires tai-sandbox-test image with ws-echo service")
|
||||
}
|
||||
|
||||
for _, pc := range testPools() {
|
||||
|
|
@ -110,7 +110,7 @@ func TestAttachSSE(t *testing.T) {
|
|||
|
||||
img := testImage()
|
||||
if img == "alpine:latest" {
|
||||
t.Skip("SSE test requires sandbox-v2-test image with sse-server service")
|
||||
t.Skip("SSE test requires tai-sandbox-test image with sse-server service")
|
||||
}
|
||||
|
||||
for _, pc := range testPools() {
|
||||
|
|
@ -159,7 +159,7 @@ func TestVNCURL(t *testing.T) {
|
|||
|
||||
img := testImage()
|
||||
if img == "alpine:latest" {
|
||||
t.Skip("VNC test requires sandbox-v2-test image with VNC desktop")
|
||||
t.Skip("VNC test requires tai-sandbox-test image with VNC desktop")
|
||||
}
|
||||
|
||||
for _, pc := range testPools() {
|
||||
|
|
@ -189,7 +189,7 @@ func TestVNCConnect(t *testing.T) {
|
|||
|
||||
img := testImage()
|
||||
if img == "alpine:latest" {
|
||||
t.Skip("VNC test requires sandbox-v2-test image with VNC desktop")
|
||||
t.Skip("VNC test requires tai-sandbox-test image with VNC desktop")
|
||||
}
|
||||
|
||||
for _, pc := range testPools() {
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
# Sandbox V2 base image — self-contained, no dependency on V1 sandbox-base
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Faster mirror for ARM64
|
||||
RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || \
|
||||
sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tini \
|
||||
curl wget git ca-certificates gnupg lsb-release jq \
|
||||
vim less tree \
|
||||
iputils-ping net-tools dnsutils telnet netcat-openbsd \
|
||||
zip unzip tar gzip \
|
||||
htop procps \
|
||||
sed gawk grep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
RUN useradd -m -s /bin/bash sandbox && \
|
||||
chown -R sandbox:sandbox /workspace
|
||||
|
||||
# yao-grpc binary (replaces yao-bridge from V1)
|
||||
ARG TARGETARCH
|
||||
COPY yao-grpc-${TARGETARCH} /usr/local/bin/yao-grpc
|
||||
RUN chmod +x /usr/local/bin/yao-grpc
|
||||
|
||||
# openai-proxy: Anthropic Messages API → OpenAI Chat Completions API
|
||||
COPY openai-proxy-${TARGETARCH} /usr/local/bin/openai-proxy
|
||||
RUN chmod +x /usr/local/bin/openai-proxy
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
USER sandbox
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
|
||||
CMD ["sleep", "infinity"]
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
#!/bin/bash
|
||||
# V2 base entrypoint — conditionally starts yao-grpc and openai-proxy
|
||||
|
||||
if [ -n "$YAO_GRPC_ADDR" ] && [ -n "$YAO_SANDBOX_ID" ]; then
|
||||
tail -f /dev/null | yao-grpc serve &
|
||||
fi
|
||||
|
||||
if [ -n "$OPENAI_PROXY_BACKEND" ]; then
|
||||
openai-proxy &
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package main
|
||||
|
||||
import proxy "github.com/yaoapp/yao/sandbox/v2/docker/bin/openai-proxy"
|
||||
|
||||
func main() {
|
||||
proxy.Main()
|
||||
}
|
||||
|
|
@ -1,419 +0,0 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) convertRequest(req *AnthropicRequest) *OpenAIRequest {
|
||||
maxTokens := req.MaxTokens
|
||||
if s.config.Options != nil {
|
||||
if mt, ok := s.config.Options["max_tokens"]; ok {
|
||||
switch v := mt.(type) {
|
||||
case float64:
|
||||
maxTokens = int(v)
|
||||
case int:
|
||||
maxTokens = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
temperature := req.Temperature
|
||||
if s.config.Options != nil {
|
||||
if temp, ok := s.config.Options["temperature"]; ok {
|
||||
if v, ok := temp.(float64); ok {
|
||||
temperature = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openaiReq := &OpenAIRequest{
|
||||
Model: s.config.Model,
|
||||
MaxTokens: maxTokens,
|
||||
Stream: req.Stream,
|
||||
Temperature: temperature,
|
||||
TopP: req.TopP,
|
||||
Stop: req.StopSequences,
|
||||
}
|
||||
|
||||
if s.config.Options != nil {
|
||||
openaiReq.ExtraOptions = make(map[string]interface{})
|
||||
for k, v := range s.config.Options {
|
||||
switch k {
|
||||
case "max_tokens", "temperature", "model", "key", "proxy":
|
||||
continue
|
||||
default:
|
||||
openaiReq.ExtraOptions[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openaiReq.Messages = s.convertMessages(req.Messages, req.System)
|
||||
|
||||
if len(req.Tools) > 0 {
|
||||
openaiReq.Tools = s.convertTools(req.Tools)
|
||||
}
|
||||
|
||||
if req.ToolChoice != nil {
|
||||
openaiReq.ToolChoice = s.convertToolChoice(req.ToolChoice)
|
||||
}
|
||||
|
||||
return openaiReq
|
||||
}
|
||||
|
||||
func (s *Server) convertMessages(msgs []AnthropicMsg, system interface{}) []OpenAIMsg {
|
||||
var result []OpenAIMsg
|
||||
|
||||
if system != nil {
|
||||
systemText := extractSystemText(system)
|
||||
if systemText != "" {
|
||||
result = append(result, OpenAIMsg{
|
||||
Role: "system",
|
||||
Content: systemText,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, msg := range msgs {
|
||||
converted := s.convertMessage(msg)
|
||||
result = append(result, converted...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) convertMessage(msg AnthropicMsg) []OpenAIMsg {
|
||||
var result []OpenAIMsg
|
||||
|
||||
switch content := msg.Content.(type) {
|
||||
case string:
|
||||
result = append(result, OpenAIMsg{
|
||||
Role: mapRole(msg.Role),
|
||||
Content: content,
|
||||
})
|
||||
|
||||
case []interface{}:
|
||||
var toolResults []ContentBlock
|
||||
var otherContent []interface{}
|
||||
|
||||
for _, item := range content {
|
||||
block := parseContentBlock(item)
|
||||
if block.Type == "tool_result" {
|
||||
toolResults = append(toolResults, block)
|
||||
} else {
|
||||
otherContent = append(otherContent, item)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tr := range toolResults {
|
||||
toolMsg := OpenAIMsg{
|
||||
Role: "tool",
|
||||
ToolCallID: tr.ToolUseID,
|
||||
Content: extractToolResultContent(tr.Content),
|
||||
}
|
||||
result = append(result, toolMsg)
|
||||
}
|
||||
|
||||
if len(otherContent) > 0 {
|
||||
openaiContent := s.convertContentBlocks(otherContent)
|
||||
if len(openaiContent) == 1 && openaiContent[0].Type == "text" {
|
||||
result = append(result, OpenAIMsg{
|
||||
Role: mapRole(msg.Role),
|
||||
Content: openaiContent[0].Text,
|
||||
})
|
||||
} else if len(openaiContent) > 0 {
|
||||
result = append(result, OpenAIMsg{
|
||||
Role: mapRole(msg.Role),
|
||||
Content: openaiContent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if msg.Role == "assistant" {
|
||||
toolCalls := extractToolUseBlocks(content)
|
||||
if len(toolCalls) > 0 {
|
||||
found := false
|
||||
for i := range result {
|
||||
if result[i].Role == "assistant" {
|
||||
result[i].ToolCalls = toolCalls
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
result = append(result, OpenAIMsg{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ToolCalls: toolCalls,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) convertContentBlocks(blocks []interface{}) []OpenAIContent {
|
||||
var result []OpenAIContent
|
||||
|
||||
for _, item := range blocks {
|
||||
block := parseContentBlock(item)
|
||||
|
||||
switch block.Type {
|
||||
case "text":
|
||||
result = append(result, OpenAIContent{
|
||||
Type: "text",
|
||||
Text: block.Text,
|
||||
})
|
||||
|
||||
case "image":
|
||||
if block.Source != nil {
|
||||
imageURL := convertImageSource(block.Source)
|
||||
result = append(result, OpenAIContent{
|
||||
Type: "image_url",
|
||||
ImageURL: imageURL,
|
||||
})
|
||||
}
|
||||
|
||||
case "tool_use", "tool_result":
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func convertImageSource(source *ImageSource) *OpenAIImageURL {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch source.Type {
|
||||
case "base64":
|
||||
mediaType := source.MediaType
|
||||
if mediaType == "" {
|
||||
mediaType = "image/jpeg"
|
||||
}
|
||||
return &OpenAIImageURL{
|
||||
URL: fmt.Sprintf("data:%s;base64,%s", mediaType, source.Data),
|
||||
}
|
||||
case "url":
|
||||
return &OpenAIImageURL{
|
||||
URL: source.URL,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) convertTools(tools []AnthropicTool) []OpenAITool {
|
||||
var result []OpenAITool
|
||||
for _, tool := range tools {
|
||||
result = append(result, OpenAITool{
|
||||
Type: "function",
|
||||
Function: OpenAIFunction{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.InputSchema,
|
||||
},
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) convertToolChoice(choice *AnthropicToolChoice) interface{} {
|
||||
if choice == nil {
|
||||
return nil
|
||||
}
|
||||
switch choice.Type {
|
||||
case "auto":
|
||||
return "auto"
|
||||
case "any":
|
||||
return "required"
|
||||
case "tool":
|
||||
return map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]string{
|
||||
"name": choice.Name,
|
||||
},
|
||||
}
|
||||
case "none":
|
||||
return "none"
|
||||
}
|
||||
return "auto"
|
||||
}
|
||||
|
||||
func (s *Server) convertResponse(resp *OpenAIResponse) *AnthropicResponse {
|
||||
result := &AnthropicResponse{
|
||||
ID: generateID("msg_"),
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: []ContentBlock{},
|
||||
Model: s.config.Model,
|
||||
}
|
||||
|
||||
if len(resp.Choices) > 0 {
|
||||
choice := resp.Choices[0]
|
||||
|
||||
if content, ok := choice.Message.Content.(string); ok && content != "" {
|
||||
result.Content = append(result.Content, ContentBlock{
|
||||
Type: "text",
|
||||
Text: content,
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
var input interface{}
|
||||
json.Unmarshal([]byte(tc.Function.Arguments), &input)
|
||||
|
||||
result.Content = append(result.Content, ContentBlock{
|
||||
Type: "tool_use",
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
stopReason := mapFinishReason(choice.FinishReason)
|
||||
result.StopReason = &stopReason
|
||||
}
|
||||
|
||||
if resp.Usage != nil {
|
||||
result.Usage = &Usage{
|
||||
InputTokens: resp.Usage.PromptTokens,
|
||||
OutputTokens: resp.Usage.CompletionTokens,
|
||||
}
|
||||
} else {
|
||||
result.Usage = &Usage{InputTokens: 0, OutputTokens: 0}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func extractSystemText(system interface{}) string {
|
||||
switch s := system.(type) {
|
||||
case string:
|
||||
return s
|
||||
case []interface{}:
|
||||
var texts []string
|
||||
for _, item := range s {
|
||||
if block, ok := item.(map[string]interface{}); ok {
|
||||
if text, ok := block["text"].(string); ok {
|
||||
if strings.HasPrefix(text, "x-anthropic-") {
|
||||
continue
|
||||
}
|
||||
texts = append(texts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(texts) > 0 {
|
||||
return strings.Join(texts, "\n\n")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseContentBlock(item interface{}) ContentBlock {
|
||||
var block ContentBlock
|
||||
switch v := item.(type) {
|
||||
case map[string]interface{}:
|
||||
if t, ok := v["type"].(string); ok {
|
||||
block.Type = t
|
||||
}
|
||||
if text, ok := v["text"].(string); ok {
|
||||
block.Text = text
|
||||
}
|
||||
if id, ok := v["id"].(string); ok {
|
||||
block.ID = id
|
||||
}
|
||||
if name, ok := v["name"].(string); ok {
|
||||
block.Name = name
|
||||
}
|
||||
if input, ok := v["input"]; ok {
|
||||
block.Input = input
|
||||
}
|
||||
if toolUseID, ok := v["tool_use_id"].(string); ok {
|
||||
block.ToolUseID = toolUseID
|
||||
}
|
||||
if content, ok := v["content"]; ok {
|
||||
block.Content = content
|
||||
}
|
||||
if isError, ok := v["is_error"].(bool); ok {
|
||||
block.IsError = isError
|
||||
}
|
||||
if source, ok := v["source"].(map[string]interface{}); ok {
|
||||
block.Source = parseImageSource(source)
|
||||
}
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
func parseImageSource(source map[string]interface{}) *ImageSource {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
result := &ImageSource{}
|
||||
if t, ok := source["type"].(string); ok {
|
||||
result.Type = t
|
||||
}
|
||||
if mediaType, ok := source["media_type"].(string); ok {
|
||||
result.MediaType = mediaType
|
||||
}
|
||||
if data, ok := source["data"].(string); ok {
|
||||
result.Data = data
|
||||
}
|
||||
if url, ok := source["url"].(string); ok {
|
||||
result.URL = url
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func extractToolUseBlocks(content []interface{}) []OpenAIToolCall {
|
||||
var result []OpenAIToolCall
|
||||
for _, item := range content {
|
||||
block := parseContentBlock(item)
|
||||
if block.Type == "tool_use" {
|
||||
args, _ := json.Marshal(block.Input)
|
||||
result = append(result, OpenAIToolCall{
|
||||
ID: block.ID,
|
||||
Type: "function",
|
||||
Function: OpenAIFunctionCall{
|
||||
Name: block.Name,
|
||||
Arguments: string(args),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func extractToolResultContent(content interface{}) string {
|
||||
switch c := content.(type) {
|
||||
case string:
|
||||
return c
|
||||
case []interface{}:
|
||||
for _, item := range c {
|
||||
if block, ok := item.(map[string]interface{}); ok {
|
||||
if block["type"] == "text" {
|
||||
if text, ok := block["text"].(string); ok {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mapRole(role string) string {
|
||||
switch role {
|
||||
case "user":
|
||||
return "user"
|
||||
case "assistant":
|
||||
return "assistant"
|
||||
default:
|
||||
return role
|
||||
}
|
||||
}
|
||||
|
|
@ -1,510 +0,0 @@
|
|||
// Package proxy provides a lightweight API proxy that translates
|
||||
// Anthropic Messages API to OpenAI Chat Completions API.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the proxy server configuration
|
||||
type Config struct {
|
||||
Port int
|
||||
Backend string
|
||||
Model string
|
||||
APIKey string
|
||||
Timeout int
|
||||
Verbose bool
|
||||
LogFile string
|
||||
Options map[string]interface{}
|
||||
}
|
||||
|
||||
// Server is the API proxy server
|
||||
type Server struct {
|
||||
config *Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// Main is the entry point for the proxy server
|
||||
func Main() {
|
||||
config := parseFlags()
|
||||
if err := config.Validate(); err != nil {
|
||||
log.Fatalf("Configuration error: %v", err)
|
||||
}
|
||||
|
||||
if config.LogFile != "" {
|
||||
f, err := os.OpenFile(config.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open log file: %v", err)
|
||||
}
|
||||
mw := io.MultiWriter(os.Stdout, f)
|
||||
log.SetOutput(mw)
|
||||
}
|
||||
|
||||
server := NewServer(config)
|
||||
addr := fmt.Sprintf(":%d", config.Port)
|
||||
|
||||
log.Printf("OpenAI Proxy starting on %s", addr)
|
||||
log.Printf("Backend: %s", config.Backend)
|
||||
log.Printf("Model: %s", config.Model)
|
||||
if len(config.Options) > 0 {
|
||||
optBytes, _ := json.Marshal(config.Options)
|
||||
log.Printf("Options: %s", string(optBytes))
|
||||
}
|
||||
|
||||
http.HandleFunc("/v1/messages", server.handleMessages)
|
||||
http.HandleFunc("/health", server.handleHealth)
|
||||
|
||||
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseFlags() *Config {
|
||||
config := &Config{}
|
||||
|
||||
flag.IntVar(&config.Port, "p", 0, "Listen port")
|
||||
flag.IntVar(&config.Port, "port", 0, "Listen port")
|
||||
flag.StringVar(&config.Backend, "b", "", "Backend API URL")
|
||||
flag.StringVar(&config.Backend, "backend", "", "Backend API URL")
|
||||
flag.StringVar(&config.Model, "m", "", "Backend model name")
|
||||
flag.StringVar(&config.Model, "model", "", "Backend model name")
|
||||
flag.StringVar(&config.APIKey, "k", "", "Backend API key")
|
||||
flag.StringVar(&config.APIKey, "api-key", "", "Backend API key")
|
||||
flag.IntVar(&config.Timeout, "t", 0, "Request timeout in seconds")
|
||||
flag.IntVar(&config.Timeout, "timeout", 0, "Request timeout in seconds")
|
||||
flag.BoolVar(&config.Verbose, "v", false, "Verbose logging")
|
||||
flag.BoolVar(&config.Verbose, "verbose", false, "Verbose logging")
|
||||
flag.StringVar(&config.LogFile, "l", "", "Log file path")
|
||||
flag.StringVar(&config.LogFile, "log", "", "Log file path")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if config.Port == 0 {
|
||||
if v := os.Getenv("OPENAI_PROXY_PORT"); v != "" {
|
||||
config.Port, _ = strconv.Atoi(v)
|
||||
}
|
||||
}
|
||||
if config.Port == 0 {
|
||||
config.Port = 3456
|
||||
}
|
||||
|
||||
if config.Backend == "" {
|
||||
config.Backend = os.Getenv("OPENAI_PROXY_BACKEND")
|
||||
}
|
||||
|
||||
if config.Model == "" {
|
||||
config.Model = os.Getenv("OPENAI_PROXY_MODEL")
|
||||
}
|
||||
|
||||
if config.APIKey == "" {
|
||||
config.APIKey = os.Getenv("OPENAI_PROXY_API_KEY")
|
||||
}
|
||||
|
||||
if config.Timeout == 0 {
|
||||
if v := os.Getenv("OPENAI_PROXY_TIMEOUT"); v != "" {
|
||||
config.Timeout, _ = strconv.Atoi(v)
|
||||
}
|
||||
}
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 300
|
||||
}
|
||||
|
||||
if optionsStr := os.Getenv("OPENAI_PROXY_OPTIONS"); optionsStr != "" {
|
||||
var options map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
|
||||
log.Printf("Warning: failed to parse OPENAI_PROXY_OPTIONS: %v", err)
|
||||
} else {
|
||||
config.Options = options
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid
|
||||
func (c *Config) Validate() error {
|
||||
if c.Backend == "" {
|
||||
return fmt.Errorf("backend URL is required (-b or OPENAI_PROXY_BACKEND)")
|
||||
}
|
||||
if c.Model == "" {
|
||||
return fmt.Errorf("model name is required (-m or OPENAI_PROXY_MODEL)")
|
||||
}
|
||||
if c.APIKey == "" {
|
||||
return fmt.Errorf("API key is required (-k or OPENAI_PROXY_API_KEY)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewServer creates a new proxy server
|
||||
func NewServer(config *Config) *Server {
|
||||
return &Server{
|
||||
config: config,
|
||||
client: &http.Client{
|
||||
Timeout: time.Duration(config.Timeout) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Failed to read request body")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if s.config.Verbose {
|
||||
log.Printf("Received request: %s", string(body))
|
||||
}
|
||||
|
||||
var anthropicReq AnthropicRequest
|
||||
if err := json.Unmarshal(body, &anthropicReq); err != nil {
|
||||
s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
openaiReq := s.convertRequest(&anthropicReq)
|
||||
|
||||
if anthropicReq.Stream {
|
||||
s.handleStreamingRequest(w, openaiReq)
|
||||
} else {
|
||||
s.handleNonStreamingRequest(w, openaiReq)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleNonStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) {
|
||||
openaiReq.Stream = false
|
||||
|
||||
resp, err := s.forwardRequest(openaiReq)
|
||||
if err != nil {
|
||||
s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
s.errorResponse(w, http.StatusBadGateway, "backend_error", "Failed to read backend response")
|
||||
return
|
||||
}
|
||||
|
||||
if s.config.Verbose {
|
||||
log.Printf("Backend response: %s", string(body))
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
w.Write(body)
|
||||
return
|
||||
}
|
||||
|
||||
var openaiResp OpenAIResponse
|
||||
if err := json.Unmarshal(body, &openaiResp); err != nil {
|
||||
s.errorResponse(w, http.StatusBadGateway, "backend_error", "Invalid backend response")
|
||||
return
|
||||
}
|
||||
|
||||
anthropicResp := s.convertResponse(&openaiResp)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(anthropicResp)
|
||||
}
|
||||
|
||||
func (s *Server) handleStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) {
|
||||
openaiReq.Stream = true
|
||||
openaiReq.StreamOptions = &StreamOptions{IncludeUsage: true}
|
||||
|
||||
resp, err := s.forwardRequest(openaiReq)
|
||||
if err != nil {
|
||||
s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
w.Write(body)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
s.errorResponse(w, http.StatusInternalServerError, "server_error", "Streaming not supported")
|
||||
return
|
||||
}
|
||||
|
||||
msgID := generateID("msg_")
|
||||
startEvent := AnthropicStreamEvent{
|
||||
Type: "message_start",
|
||||
Message: &AnthropicResponse{
|
||||
ID: msgID,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: []ContentBlock{},
|
||||
Model: s.config.Model,
|
||||
StopReason: nil,
|
||||
StopSequence: nil,
|
||||
Usage: &Usage{InputTokens: 0, OutputTokens: 0},
|
||||
},
|
||||
}
|
||||
s.writeSSE(w, flusher, startEvent)
|
||||
|
||||
s.processStream(w, flusher, resp.Body, msgID)
|
||||
}
|
||||
|
||||
func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body io.Reader, msgID string) {
|
||||
scanner := bufio.NewScanner(body)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
|
||||
var contentBlockStarted bool
|
||||
var currentToolCall *ToolCallAccumulator
|
||||
var toolCalls []*ToolCallAccumulator
|
||||
var contentIndex int
|
||||
var finishReason string
|
||||
var lastUsage *Usage
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
var chunk OpenAIStreamChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
if s.config.Verbose {
|
||||
log.Printf("Failed to parse chunk: %s", data)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(chunk.Choices) == 0 {
|
||||
if chunk.Usage != nil {
|
||||
lastUsage = &Usage{
|
||||
InputTokens: chunk.Usage.PromptTokens,
|
||||
OutputTokens: chunk.Usage.CompletionTokens,
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
choice := chunk.Choices[0]
|
||||
|
||||
if choice.FinishReason != "" {
|
||||
finishReason = mapFinishReason(choice.FinishReason)
|
||||
}
|
||||
|
||||
if len(choice.Delta.ToolCalls) > 0 {
|
||||
for _, tc := range choice.Delta.ToolCalls {
|
||||
if tc.Index != nil {
|
||||
idx := *tc.Index
|
||||
if idx >= len(toolCalls) {
|
||||
if contentBlockStarted && currentToolCall == nil {
|
||||
stopEvent := AnthropicStreamEvent{
|
||||
Type: "content_block_stop",
|
||||
Index: contentIndex - 1,
|
||||
}
|
||||
s.writeSSE(w, flusher, stopEvent)
|
||||
}
|
||||
|
||||
currentToolCall = &ToolCallAccumulator{
|
||||
Index: idx,
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Args: "",
|
||||
}
|
||||
toolCalls = append(toolCalls, currentToolCall)
|
||||
|
||||
startEvent := AnthropicStreamEvent{
|
||||
Type: "content_block_start",
|
||||
Index: contentIndex,
|
||||
ContentBlock: &ContentBlock{
|
||||
Type: "tool_use",
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
s.writeSSE(w, flusher, startEvent)
|
||||
contentIndex++
|
||||
}
|
||||
|
||||
if tc.Function.Arguments != "" {
|
||||
currentToolCall.Args += tc.Function.Arguments
|
||||
deltaEvent := AnthropicStreamEvent{
|
||||
Type: "content_block_delta",
|
||||
Index: contentIndex - 1,
|
||||
Delta: &DeltaContent{
|
||||
Type: "input_json_delta",
|
||||
PartialJSON: tc.Function.Arguments,
|
||||
},
|
||||
}
|
||||
s.writeSSE(w, flusher, deltaEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if choice.Delta.Content != "" {
|
||||
if !contentBlockStarted {
|
||||
startEvent := AnthropicStreamEvent{
|
||||
Type: "content_block_start",
|
||||
Index: contentIndex,
|
||||
ContentBlock: &ContentBlock{
|
||||
Type: "text",
|
||||
Text: "",
|
||||
},
|
||||
}
|
||||
s.writeSSE(w, flusher, startEvent)
|
||||
contentBlockStarted = true
|
||||
contentIndex++
|
||||
}
|
||||
|
||||
deltaEvent := AnthropicStreamEvent{
|
||||
Type: "content_block_delta",
|
||||
Index: contentIndex - 1,
|
||||
Delta: &DeltaContent{
|
||||
Type: "text_delta",
|
||||
Text: choice.Delta.Content,
|
||||
},
|
||||
}
|
||||
s.writeSSE(w, flusher, deltaEvent)
|
||||
}
|
||||
}
|
||||
|
||||
if contentBlockStarted || len(toolCalls) > 0 {
|
||||
stopEvent := AnthropicStreamEvent{
|
||||
Type: "content_block_stop",
|
||||
Index: contentIndex - 1,
|
||||
}
|
||||
s.writeSSE(w, flusher, stopEvent)
|
||||
}
|
||||
|
||||
if finishReason == "" {
|
||||
finishReason = "end_turn"
|
||||
}
|
||||
if lastUsage == nil {
|
||||
lastUsage = &Usage{InputTokens: 0, OutputTokens: 0}
|
||||
}
|
||||
deltaEvent := AnthropicStreamEvent{
|
||||
Type: "message_delta",
|
||||
Delta: &DeltaContent{
|
||||
StopReason: &finishReason,
|
||||
},
|
||||
Usage: lastUsage,
|
||||
}
|
||||
s.writeSSE(w, flusher, deltaEvent)
|
||||
|
||||
stopEvent := AnthropicStreamEvent{
|
||||
Type: "message_stop",
|
||||
}
|
||||
s.writeSSE(w, flusher, stopEvent)
|
||||
}
|
||||
|
||||
func (s *Server) writeSSE(w http.ResponseWriter, flusher http.Flusher, event interface{}) {
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
eventType := ""
|
||||
if e, ok := event.(AnthropicStreamEvent); ok {
|
||||
eventType = e.Type
|
||||
}
|
||||
|
||||
if eventType != "" {
|
||||
fmt.Fprintf(w, "event: %s\n", eventType)
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
|
||||
if s.config.Verbose {
|
||||
log.Printf("SSE event: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) forwardRequest(openaiReq *OpenAIRequest) (*http.Response, error) {
|
||||
body, err := json.Marshal(openaiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.config.Verbose {
|
||||
log.Printf("Forwarding to backend: %s", string(body))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, s.config.Backend, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.config.APIKey)
|
||||
|
||||
return s.client.Do(req)
|
||||
}
|
||||
|
||||
func (s *Server) errorResponse(w http.ResponseWriter, status int, errType, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"type": "error",
|
||||
"error": map[string]string{
|
||||
"type": errType,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func generateID(prefix string) string {
|
||||
return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func mapFinishReason(reason string) string {
|
||||
switch reason {
|
||||
case "stop":
|
||||
return "end_turn"
|
||||
case "length":
|
||||
return "max_tokens"
|
||||
case "tool_calls", "function_call":
|
||||
return "tool_use"
|
||||
case "content_filter":
|
||||
return "end_turn"
|
||||
default:
|
||||
return "end_turn"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
package proxy
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ============================================
|
||||
// Anthropic API Types
|
||||
// ============================================
|
||||
|
||||
type AnthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []AnthropicMsg `json:"messages"`
|
||||
System interface{} `json:"system,omitempty"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
TopK *int `json:"top_k,omitempty"`
|
||||
StopSequences []string `json:"stop_sequences,omitempty"`
|
||||
Tools []AnthropicTool `json:"tools,omitempty"`
|
||||
ToolChoice *AnthropicToolChoice `json:"tool_choice,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type AnthropicMsg struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Source *ImageSource `json:"source,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input interface{} `json:"input,omitempty"`
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Content interface{} `json:"content,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
}
|
||||
|
||||
type ImageSource struct {
|
||||
Type string `json:"type"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type SystemBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type AnthropicTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema interface{} `json:"input_schema"`
|
||||
}
|
||||
|
||||
type AnthropicToolChoice struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type AnthropicResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []ContentBlock `json:"content"`
|
||||
Model string `json:"model"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
StopSequence *string `json:"stop_sequence,omitempty"`
|
||||
Usage *Usage `json:"usage"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
type AnthropicStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Index int `json:"index,omitempty"`
|
||||
Message *AnthropicResponse `json:"message,omitempty"`
|
||||
ContentBlock *ContentBlock `json:"content_block,omitempty"`
|
||||
Delta *DeltaContent `json:"delta,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type DeltaContent struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
PartialJSON string `json:"partial_json,omitempty"`
|
||||
StopReason *string `json:"stop_reason,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// OpenAI API Types
|
||||
// ============================================
|
||||
|
||||
type OpenAIRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []OpenAIMsg `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stop []string `json:"stop,omitempty"`
|
||||
Tools []OpenAITool `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
ExtraOptions map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
func (r OpenAIRequest) MarshalJSON() ([]byte, error) {
|
||||
m := map[string]interface{}{
|
||||
"model": r.Model,
|
||||
"messages": r.Messages,
|
||||
}
|
||||
if r.MaxTokens > 0 {
|
||||
m["max_tokens"] = r.MaxTokens
|
||||
}
|
||||
if r.Stream {
|
||||
m["stream"] = r.Stream
|
||||
}
|
||||
if r.StreamOptions != nil {
|
||||
m["stream_options"] = r.StreamOptions
|
||||
}
|
||||
if r.Temperature != nil {
|
||||
m["temperature"] = *r.Temperature
|
||||
}
|
||||
if r.TopP != nil {
|
||||
m["top_p"] = *r.TopP
|
||||
}
|
||||
if len(r.Stop) > 0 {
|
||||
m["stop"] = r.Stop
|
||||
}
|
||||
if len(r.Tools) > 0 {
|
||||
m["tools"] = r.Tools
|
||||
}
|
||||
if r.ToolChoice != nil {
|
||||
m["tool_choice"] = r.ToolChoice
|
||||
}
|
||||
for k, v := range r.ExtraOptions {
|
||||
if _, exists := m[k]; !exists {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
type StreamOptions struct {
|
||||
IncludeUsage bool `json:"include_usage"`
|
||||
}
|
||||
|
||||
type OpenAIMsg struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content,omitempty"`
|
||||
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL *OpenAIImageURL `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIImageURL struct {
|
||||
URL string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAITool struct {
|
||||
Type string `json:"type"`
|
||||
Function OpenAIFunction `json:"function"`
|
||||
}
|
||||
|
||||
type OpenAIFunction struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters interface{} `json:"parameters"`
|
||||
}
|
||||
|
||||
type OpenAIToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function OpenAIFunctionCall `json:"function"`
|
||||
Index *int `json:"index,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIFunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type OpenAIResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []OpenAIChoice `json:"choices"`
|
||||
Usage *OpenAIUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIChoice struct {
|
||||
Index int `json:"index"`
|
||||
Message OpenAIMsg `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type OpenAIUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
type OpenAIStreamChunk struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []OpenAIStreamChoice `json:"choices"`
|
||||
Usage *OpenAIUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIStreamChoice struct {
|
||||
Index int `json:"index"`
|
||||
Delta OpenAIStreamDelta `json:"delta"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIStreamDelta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCallAccumulator struct {
|
||||
Index int
|
||||
ID string
|
||||
Name string
|
||||
Args string
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Build script for Sandbox V2 Docker images (base + test)
|
||||
# Usage: ./build.sh [true|false] — push to registry or build locally
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PUSH=${1:-false}
|
||||
REGISTRY=${REGISTRY:-"yaoapp"}
|
||||
YAO_ROOT="$SCRIPT_DIR/../../.."
|
||||
|
||||
echo "=== Building Sandbox V2 Images ==="
|
||||
echo "Push: $PUSH"
|
||||
echo "Registry: $REGISTRY"
|
||||
|
||||
# --- Cross-compile Go binaries ---
|
||||
|
||||
echo ""
|
||||
echo "=== Building yao-grpc (multi-arch) ==="
|
||||
cd "$YAO_ROOT/tai/grpc/cmd"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/yao-grpc-amd64" .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/yao-grpc-arm64" .
|
||||
echo "Built: yao-grpc-amd64, yao-grpc-arm64"
|
||||
|
||||
echo ""
|
||||
echo "=== Building openai-proxy (multi-arch) ==="
|
||||
cd "$SCRIPT_DIR/bin/openai-proxy/cmd/openai-proxy"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/openai-proxy-amd64" .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/openai-proxy-arm64" .
|
||||
echo "Built: openai-proxy-amd64, openai-proxy-arm64"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# --- Setup buildx ---
|
||||
|
||||
BUILDER_NAME="yao-multiarch"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
echo "Creating buildx builder: $BUILDER_NAME"
|
||||
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
|
||||
build_image() {
|
||||
local IMAGE_NAME=$1
|
||||
local CONTEXT_DIR=$2
|
||||
local PUSH_FLAG=$3
|
||||
|
||||
echo ""
|
||||
echo "=== Building $IMAGE_NAME (linux/amd64,linux/arm64) ==="
|
||||
|
||||
local BUILD_ARGS="--platform linux/amd64,linux/arm64 -t ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
|
||||
if [ "$PUSH_FLAG" = "true" ]; then
|
||||
BUILD_ARGS="$BUILD_ARGS --push"
|
||||
else
|
||||
echo "Note: Multi-arch build without push. Building for current platform only."
|
||||
BUILD_ARGS="--load -t ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
fi
|
||||
|
||||
docker buildx build $BUILD_ARGS -f "$CONTEXT_DIR/Dockerfile" "$CONTEXT_DIR"
|
||||
}
|
||||
|
||||
# --- Build images ---
|
||||
|
||||
build_image "sandbox-v2-base" "$SCRIPT_DIR/base" "$PUSH"
|
||||
build_image "sandbox-v2-test" "$SCRIPT_DIR/test" "$PUSH"
|
||||
|
||||
# --- Cleanup binaries ---
|
||||
|
||||
echo ""
|
||||
echo "=== Cleanup ==="
|
||||
rm -f "$SCRIPT_DIR/base/yao-grpc-amd64" "$SCRIPT_DIR/base/yao-grpc-arm64"
|
||||
rm -f "$SCRIPT_DIR/base/openai-proxy-amd64" "$SCRIPT_DIR/base/openai-proxy-arm64"
|
||||
echo "Removed temporary binary files"
|
||||
|
||||
echo ""
|
||||
echo "=== Build complete ==="
|
||||
docker images | grep -E "sandbox-v2" | head -10 || true
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
# Sandbox V2 test image — adds test services + VNC desktop on top of v2-base
|
||||
FROM yaoapp/sandbox-v2-base:latest
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
nginx \
|
||||
python3 \
|
||||
python3-pip \
|
||||
xvfb \
|
||||
x11vnc \
|
||||
fluxbox \
|
||||
xterm \
|
||||
&& pip3 install --break-system-packages websockets websockify \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Test service scripts
|
||||
COPY ws-echo.py /opt/test/ws-echo.py
|
||||
COPY sse-server.py /opt/test/sse-server.py
|
||||
COPY entrypoint.sh /test-entrypoint.sh
|
||||
RUN chmod +x /test-entrypoint.sh
|
||||
|
||||
ENV DISPLAY=:99
|
||||
|
||||
USER sandbox
|
||||
EXPOSE 5900 6080
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/test-entrypoint.sh"]
|
||||
CMD ["sleep", "infinity"]
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
#!/bin/bash
|
||||
# V2 test entrypoint — starts test services + VNC desktop then delegates to base entrypoint
|
||||
|
||||
# Start Xvfb (virtual framebuffer)
|
||||
Xvfb :99 -screen 0 1024x768x24 -ac +extension GLX +render -noreset &
|
||||
sleep 0.5
|
||||
|
||||
# Start fluxbox window manager
|
||||
fluxbox &
|
||||
|
||||
# Start x11vnc (raw RFB on 5900)
|
||||
x11vnc -display :99 -rfbport 5900 -nopw -shared -forever -xkb -ncache 10 &
|
||||
sleep 0.3
|
||||
|
||||
# Start websockify (WebSocket on 6080 → RFB 5900)
|
||||
websockify 0.0.0.0:6080 localhost:5900 &
|
||||
|
||||
# Test services
|
||||
python3 /opt/test/ws-echo.py &
|
||||
python3 /opt/test/sse-server.py &
|
||||
|
||||
exec /entrypoint.sh "$@"
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
"""Minimal SSE server on port 9801 using only stdlib.
|
||||
Sends a 'hello' event every second, up to 5 events then closes."""
|
||||
import http.server
|
||||
import time
|
||||
|
||||
class SSEHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
self.end_headers()
|
||||
|
||||
for i in range(5):
|
||||
msg = f"data: hello-{i}\n\n"
|
||||
try:
|
||||
self.wfile.write(msg.encode())
|
||||
self.wfile.flush()
|
||||
except BrokenPipeError:
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = http.server.HTTPServer(("0.0.0.0", 9801), SSEHandler)
|
||||
server.serve_forever()
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
"""WebSocket echo server on port 9800 using the websockets library."""
|
||||
import asyncio
|
||||
import websockets
|
||||
|
||||
async def echo(ws):
|
||||
async for msg in ws:
|
||||
await ws.send(msg)
|
||||
|
||||
async def main():
|
||||
async with websockets.serve(echo, "0.0.0.0", 9800):
|
||||
await asyncio.Future()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -15,8 +16,23 @@ import (
|
|||
// requests internally without an HTTP round-trip.
|
||||
var Router *gin.Engine
|
||||
|
||||
// Start the yao service
|
||||
func Start(cfg config.Config) (*http.Server, error) {
|
||||
// ServerHooks allows the caller to inject gRPC (or other) server lifecycle
|
||||
// without creating import cycles.
|
||||
type ServerHooks struct {
|
||||
Start func(cfg config.Config) error // called before HTTP starts; nil = skip
|
||||
Stop func() // called on shutdown; nil = skip
|
||||
Addrs func() []string // returns listen addresses; nil = skip
|
||||
}
|
||||
|
||||
// Service manages HTTP and optional gRPC servers as a single unit.
|
||||
type Service struct {
|
||||
http *http.Server
|
||||
hooks ServerHooks
|
||||
}
|
||||
|
||||
// Start launches optional hook servers (e.g. gRPC) and the HTTP server.
|
||||
// Returns a Service handle for shutdown coordination.
|
||||
func Start(cfg config.Config, hooks ...ServerHooks) (*Service, error) {
|
||||
|
||||
if cfg.AllowFrom == nil {
|
||||
cfg.AllowFrom = []string{}
|
||||
|
|
@ -27,29 +43,31 @@ func Start(cfg config.Config) (*http.Server, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
var h ServerHooks
|
||||
if len(hooks) > 0 {
|
||||
h = hooks[0]
|
||||
}
|
||||
|
||||
// Start hook server (gRPC, etc.)
|
||||
if h.Start != nil {
|
||||
if err := h.Start(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
Router = router
|
||||
router.Use(Middlewares...)
|
||||
|
||||
var apiRoot string
|
||||
if openapi.Server != nil {
|
||||
// OpenAPI mode: use OAuth guards and dynamic routing
|
||||
apiRoot = openapi.Server.Config.BaseURL
|
||||
api.SetGuards(OpenAPIGuards())
|
||||
|
||||
// Developer APIs: use dynamic proxy (supports hot-reload)
|
||||
router.Any(apiRoot+"/api/*path", DynamicAPIHandler)
|
||||
|
||||
// Widgets and system APIs: static registration
|
||||
api.SetRoutes(router, apiRoot, cfg.AllowFrom...)
|
||||
|
||||
// Build route table for dynamic lookup
|
||||
api.BuildRouteTable()
|
||||
|
||||
// Attach OpenAPI built-in features
|
||||
openapi.Server.Attach(router)
|
||||
} else {
|
||||
// Traditional mode: unchanged
|
||||
apiRoot = "/api"
|
||||
api.SetGuards(Guards)
|
||||
api.SetRoutes(router, "/api", cfg.AllowFrom...)
|
||||
|
|
@ -63,21 +81,57 @@ func Start(cfg config.Config) (*http.Server, error) {
|
|||
Timeout: 5 * time.Second,
|
||||
})
|
||||
|
||||
// Start HTTP in background; wait for the first event to confirm
|
||||
// the port is bound before returning.
|
||||
go func() {
|
||||
err = srv.Start()
|
||||
srv.Start()
|
||||
}()
|
||||
|
||||
return srv, nil
|
||||
// Block until HTTP reports READY or ERROR
|
||||
ev := <-srv.Event()
|
||||
if ev != http.READY {
|
||||
if h.Stop != nil {
|
||||
h.Stop()
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP server failed to start on %s:%d", cfg.Host, cfg.Port)
|
||||
}
|
||||
|
||||
return &Service{http: srv, hooks: h}, nil
|
||||
}
|
||||
|
||||
// Restart the yao service
|
||||
func Restart(srv *http.Server, cfg config.Config) error {
|
||||
// Event returns the HTTP server event channel (READY, CLOSED, ERROR).
|
||||
func (s *Service) Event() chan uint8 {
|
||||
return s.http.Event()
|
||||
}
|
||||
|
||||
// Stop shuts down hook servers (gRPC, etc.) then signals the HTTP server to close.
|
||||
func (s *Service) Stop() {
|
||||
if s.hooks.Stop != nil {
|
||||
s.hooks.Stop()
|
||||
}
|
||||
s.http.Stop()
|
||||
}
|
||||
|
||||
// HookAddrs returns the hook server listen addresses (e.g. gRPC addresses).
|
||||
func (s *Service) HookAddrs() []string {
|
||||
if s.hooks.Addrs != nil {
|
||||
return s.hooks.Addrs()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Watch starts file watching in development mode. Blocking; run in a goroutine.
|
||||
func (s *Service) Watch(done chan uint8) {
|
||||
watch(s, done)
|
||||
}
|
||||
|
||||
// Restart the HTTP server with a fresh router (hook servers stay running).
|
||||
func Restart(svc *Service, cfg config.Config) error {
|
||||
router := gin.New()
|
||||
Router = router
|
||||
router.Use(Middlewares...)
|
||||
|
||||
if openapi.Server != nil {
|
||||
// OpenAPI mode
|
||||
baseURL := openapi.Server.Config.BaseURL
|
||||
api.SetGuards(OpenAPIGuards())
|
||||
router.Any(baseURL+"/api/*path", DynamicAPIHandler)
|
||||
|
|
@ -85,28 +139,15 @@ func Restart(srv *http.Server, cfg config.Config) error {
|
|||
api.BuildRouteTable()
|
||||
openapi.Server.Attach(router)
|
||||
} else {
|
||||
// Traditional mode: unchanged
|
||||
api.SetGuards(Guards)
|
||||
api.SetRoutes(router, "/api", cfg.AllowFrom...)
|
||||
}
|
||||
|
||||
srv.Reset(router)
|
||||
return srv.Restart()
|
||||
}
|
||||
|
||||
// Stop the yao service
|
||||
func Stop(srv *http.Server) error {
|
||||
err := srv.Stop()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
<-srv.Event()
|
||||
return nil
|
||||
svc.http.Reset(router)
|
||||
return svc.http.Restart()
|
||||
}
|
||||
|
||||
func prepare() error {
|
||||
|
||||
// Session server
|
||||
err := share.SessionStart()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -31,24 +31,12 @@ func TestStartStop(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Stop(srv)
|
||||
defer srv.Stop()
|
||||
|
||||
<-srv.Event()
|
||||
if !srv.Ready() {
|
||||
t.Fatal("server not ready")
|
||||
}
|
||||
|
||||
port, err := srv.Port()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if port <= 0 {
|
||||
t.Fatal("invalid port")
|
||||
}
|
||||
|
||||
// API Server
|
||||
req := test.NewRequest(port).Route("/api/__yao/app/setting")
|
||||
req := test.NewRequest(cfg.Port).Route("/api/__yao/app/setting")
|
||||
res, err := req.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -58,11 +46,10 @@ func TestStartStop(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// assert.Equal(t, "Demo Application", data["name"])
|
||||
assert.True(t, len(data["name"].(string)) > 0)
|
||||
|
||||
// Public
|
||||
req = test.NewRequest(port).Route("/")
|
||||
req = test.NewRequest(cfg.Port).Route("/")
|
||||
res, err = req.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -71,7 +58,7 @@ func TestStartStop(t *testing.T) {
|
|||
assert.Equal(t, "Hello World\n", res.Body())
|
||||
|
||||
// XGEN
|
||||
req = test.NewRequest(port).Route("/admin/")
|
||||
req = test.NewRequest(cfg.Port).Route("/admin/")
|
||||
res, err = req.Get()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
|
|||
|
|
@ -6,14 +6,13 @@ import (
|
|||
|
||||
"github.com/fatih/color"
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/server/http"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/engine"
|
||||
"github.com/yaoapp/yao/openapi"
|
||||
)
|
||||
|
||||
// Watch the application code change for hot update
|
||||
func Watch(srv *http.Server, interrupt chan uint8) (err error) {
|
||||
func watch(svc *Service, interrupt chan uint8) error {
|
||||
|
||||
if application.App == nil {
|
||||
return fmt.Errorf("Application is not initialized")
|
||||
|
|
@ -24,23 +23,19 @@ func Watch(srv *http.Server, interrupt chan uint8) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
// Reload
|
||||
err = engine.Reload(config.Conf, engine.LoadOption{Action: "watch"})
|
||||
err := engine.Reload(config.Conf, engine.LoadOption{Action: "watch"})
|
||||
if err != nil {
|
||||
fmt.Println(color.RedString("[Watch] Reload: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
fmt.Println(color.GreenString("[Watch] Reload Completed"))
|
||||
|
||||
// Model
|
||||
if strings.HasPrefix(name, "/models") {
|
||||
fmt.Println(color.GreenString("[Watch] Model: %s changed (Please run yao migrate manually)", name))
|
||||
}
|
||||
|
||||
// API changes: hot reload or restart
|
||||
if strings.HasPrefix(name, "/apis") {
|
||||
if openapi.Server != nil {
|
||||
// OpenAPI mode: hot reload (no server restart needed)
|
||||
err = ReloadAPIs()
|
||||
if err != nil {
|
||||
fmt.Println(color.RedString("[Watch] Reload APIs: %s", err.Error()))
|
||||
|
|
@ -48,8 +43,7 @@ func Watch(srv *http.Server, interrupt chan uint8) (err error) {
|
|||
}
|
||||
fmt.Println(color.GreenString("[Watch] APIs Reloaded"))
|
||||
} else {
|
||||
// Traditional mode: restart server
|
||||
err = Restart(srv, config.Conf)
|
||||
err = Restart(svc, config.Conf)
|
||||
if err != nil {
|
||||
fmt.Println(color.RedString("[Watch] Restart: %s", err.Error()))
|
||||
return
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ func TestWatch(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Stop(srv)
|
||||
defer srv.Stop()
|
||||
|
||||
done := make(chan uint8, 1)
|
||||
go Watch(srv, done)
|
||||
go srv.Watch(done)
|
||||
|
||||
select {
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
|
|
|
|||
205
tai/api/register.go
Normal file
205
tai/api/register.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/openapi/oauth"
|
||||
"github.com/yaoapp/yao/tai/registry"
|
||||
)
|
||||
|
||||
// authenticateBearer validates a Bearer token and returns the caller's identity.
|
||||
// Package-level var so tests can inject a mock without an OAuth service.
|
||||
var authenticateBearer = authenticateBearerDefault
|
||||
|
||||
func authenticateBearerDefault(token string) (registry.AuthInfo, error) {
|
||||
svc := oauth.OAuth
|
||||
if svc == nil {
|
||||
return registry.AuthInfo{}, fmt.Errorf("oauth service not initialized")
|
||||
}
|
||||
result, err := svc.AuthenticateToken(oauth.AuthInput{AccessToken: token})
|
||||
if err != nil {
|
||||
return registry.AuthInfo{}, err
|
||||
}
|
||||
info := registry.AuthInfo{}
|
||||
if result.Info != nil {
|
||||
info.Subject = result.Info.Subject
|
||||
info.UserID = result.Info.UserID
|
||||
info.ClientID = result.Info.ClientID
|
||||
info.Scope = result.Info.Scope
|
||||
info.TeamID = result.Info.TeamID
|
||||
info.TenantID = result.Info.TenantID
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func extractBearer(r *http.Request) string {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if len(auth) > 7 && strings.EqualFold(auth[:7], "bearer ") {
|
||||
return auth[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// registerRequest is the JSON body for POST /tai-nodes/register.
|
||||
type registerRequest struct {
|
||||
TaiID string `json:"tai_id"`
|
||||
MachineID string `json:"machine_id"`
|
||||
Version string `json:"version"`
|
||||
Addr string `json:"addr"`
|
||||
Ports map[string]int `json:"ports"`
|
||||
Capabilities map[string]bool `json:"capabilities"`
|
||||
System registry.SystemInfo `json:"system"`
|
||||
}
|
||||
|
||||
// heartbeatRequest is the JSON body for POST /tai-nodes/heartbeat.
|
||||
type heartbeatRequest struct {
|
||||
TaiID string `json:"tai_id"`
|
||||
}
|
||||
|
||||
// HandleRegister handles POST /tai-nodes/register.
|
||||
// Validates Bearer token, extracts AuthInfo, and writes the node to the Registry.
|
||||
func HandleRegister(c *gin.Context) {
|
||||
reg := registry.Global()
|
||||
if reg == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "registry not initialized"})
|
||||
return
|
||||
}
|
||||
|
||||
bearer := extractBearer(c.Request)
|
||||
if bearer == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
|
||||
return
|
||||
}
|
||||
|
||||
authInfo, err := authenticateBearer(bearer)
|
||||
if err != nil {
|
||||
slog.Warn("tai register auth failed", "err", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req registerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if req.TaiID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tai_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
node := ®istry.TaiNode{
|
||||
TaiID: req.TaiID,
|
||||
MachineID: req.MachineID,
|
||||
Version: req.Version,
|
||||
Auth: authInfo,
|
||||
System: req.System,
|
||||
Mode: "direct",
|
||||
Addr: req.Addr,
|
||||
Ports: req.Ports,
|
||||
Capabilities: req.Capabilities,
|
||||
}
|
||||
reg.Register(node)
|
||||
|
||||
remoteIP := c.ClientIP()
|
||||
slog.Info("tai node registered via API",
|
||||
"tai_id", req.TaiID, "remote_ip", remoteIP, "user_id", authInfo.UserID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "registered",
|
||||
"tai_id": req.TaiID,
|
||||
"remote_ip": remoteIP,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleHeartbeat handles POST /tai-nodes/heartbeat.
|
||||
// Validates Bearer token and updates the node's last ping timestamp.
|
||||
func HandleHeartbeat(c *gin.Context) {
|
||||
reg := registry.Global()
|
||||
if reg == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "registry not initialized"})
|
||||
return
|
||||
}
|
||||
|
||||
bearer := extractBearer(c.Request)
|
||||
if bearer == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
|
||||
return
|
||||
}
|
||||
|
||||
authInfo, err := authenticateBearer(bearer)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req heartbeatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if req.TaiID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tai_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
snap, ok := reg.Get(req.TaiID)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tai node not found"})
|
||||
return
|
||||
}
|
||||
if snap.Auth.ClientID != authInfo.ClientID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "tai_id does not belong to this client"})
|
||||
return
|
||||
}
|
||||
|
||||
reg.UpdatePing(req.TaiID)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// HandleUnregister handles DELETE /tai-nodes/register/:tai_id.
|
||||
// Validates Bearer token, checks ownership, and removes the node.
|
||||
func HandleUnregister(c *gin.Context) {
|
||||
reg := registry.Global()
|
||||
if reg == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "registry not initialized"})
|
||||
return
|
||||
}
|
||||
|
||||
bearer := extractBearer(c.Request)
|
||||
if bearer == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
|
||||
return
|
||||
}
|
||||
|
||||
authInfo, err := authenticateBearer(bearer)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
|
||||
return
|
||||
}
|
||||
|
||||
taiID := c.Param("tai_id")
|
||||
if taiID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tai_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
snap, ok := reg.Get(taiID)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tai node not found"})
|
||||
return
|
||||
}
|
||||
if snap.Auth.ClientID != authInfo.ClientID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "tai_id does not belong to this client"})
|
||||
return
|
||||
}
|
||||
|
||||
reg.Unregister(taiID)
|
||||
slog.Info("tai node unregistered via API", "tai_id", taiID, "user_id", authInfo.UserID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "unregistered"})
|
||||
}
|
||||
267
tai/api/register_test.go
Normal file
267
tai/api/register_test.go
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/tai/registry"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func setupTest() func() {
|
||||
r := registry.NewForTest()
|
||||
registry.SetGlobalForTest(r)
|
||||
|
||||
origAuth := authenticateBearer
|
||||
authenticateBearer = func(token string) (registry.AuthInfo, error) {
|
||||
return registry.AuthInfo{
|
||||
Subject: "sub-001",
|
||||
UserID: "user-alice",
|
||||
ClientID: "tai-abc123",
|
||||
Scope: "tai:connect",
|
||||
TeamID: "team-dev",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return func() {
|
||||
authenticateBearer = origAuth
|
||||
registry.SetGlobalForTest(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonBody(v interface{}) *bytes.Buffer {
|
||||
b, _ := json.Marshal(v)
|
||||
return bytes.NewBuffer(b)
|
||||
}
|
||||
|
||||
func TestHandleRegister_Success(t *testing.T) {
|
||||
teardown := setupTest()
|
||||
defer teardown()
|
||||
|
||||
body := registerRequest{
|
||||
TaiID: "tai-abc123",
|
||||
MachineID: "m-001",
|
||||
Version: "0.2.0",
|
||||
Addr: "192.168.1.100",
|
||||
Ports: map[string]int{"grpc": 9100, "http": 8080},
|
||||
Capabilities: map[string]bool{"docker": true, "host_exec": false},
|
||||
System: registry.SystemInfo{
|
||||
OS: "linux", Arch: "amd64", Hostname: "docker-host-01", NumCPU: 16,
|
||||
},
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/tai-nodes/register", jsonBody(body))
|
||||
c.Request.Header.Set("Authorization", "Bearer test-token")
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
HandleRegister(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["status"] != "registered" {
|
||||
t.Errorf("status = %v, want registered", resp["status"])
|
||||
}
|
||||
if resp["tai_id"] != "tai-abc123" {
|
||||
t.Errorf("tai_id = %v, want tai-abc123", resp["tai_id"])
|
||||
}
|
||||
if _, ok := resp["remote_ip"]; !ok {
|
||||
t.Error("response missing remote_ip")
|
||||
}
|
||||
|
||||
snap, ok := registry.Global().Get("tai-abc123")
|
||||
if !ok {
|
||||
t.Fatal("node not found in registry after register")
|
||||
}
|
||||
if snap.Mode != "direct" {
|
||||
t.Errorf("Mode = %q, want direct", snap.Mode)
|
||||
}
|
||||
if snap.System.OS != "linux" {
|
||||
t.Errorf("System.OS = %q, want linux", snap.System.OS)
|
||||
}
|
||||
if snap.Auth.UserID != "user-alice" {
|
||||
t.Errorf("Auth.UserID = %q, want user-alice", snap.Auth.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegister_MissingAuth(t *testing.T) {
|
||||
teardown := setupTest()
|
||||
defer teardown()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/tai-nodes/register", jsonBody(registerRequest{TaiID: "x"}))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
HandleRegister(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegister_MissingTaiID(t *testing.T) {
|
||||
teardown := setupTest()
|
||||
defer teardown()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/tai-nodes/register", jsonBody(registerRequest{}))
|
||||
c.Request.Header.Set("Authorization", "Bearer test-token")
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
HandleRegister(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHeartbeat_Success(t *testing.T) {
|
||||
teardown := setupTest()
|
||||
defer teardown()
|
||||
|
||||
reg := registry.Global()
|
||||
reg.Register(®istry.TaiNode{
|
||||
TaiID: "tai-abc123",
|
||||
Mode: "direct",
|
||||
Auth: registry.AuthInfo{ClientID: "tai-abc123"},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/tai-nodes/heartbeat",
|
||||
jsonBody(heartbeatRequest{TaiID: "tai-abc123"}))
|
||||
c.Request.Header.Set("Authorization", "Bearer test-token")
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
HandleHeartbeat(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHeartbeat_WrongOwner(t *testing.T) {
|
||||
teardown := setupTest()
|
||||
defer teardown()
|
||||
|
||||
reg := registry.Global()
|
||||
reg.Register(®istry.TaiNode{
|
||||
TaiID: "tai-other",
|
||||
Mode: "direct",
|
||||
Auth: registry.AuthInfo{ClientID: "different-client"},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/tai-nodes/heartbeat",
|
||||
jsonBody(heartbeatRequest{TaiID: "tai-other"}))
|
||||
c.Request.Header.Set("Authorization", "Bearer test-token")
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
HandleHeartbeat(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHeartbeat_NotFound(t *testing.T) {
|
||||
teardown := setupTest()
|
||||
defer teardown()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/tai-nodes/heartbeat",
|
||||
jsonBody(heartbeatRequest{TaiID: "ghost"}))
|
||||
c.Request.Header.Set("Authorization", "Bearer test-token")
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
HandleHeartbeat(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUnregister_Success(t *testing.T) {
|
||||
teardown := setupTest()
|
||||
defer teardown()
|
||||
|
||||
reg := registry.Global()
|
||||
reg.Register(®istry.TaiNode{
|
||||
TaiID: "tai-abc123",
|
||||
Mode: "direct",
|
||||
Auth: registry.AuthInfo{ClientID: "tai-abc123"},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("DELETE", "/tai-nodes/register/tai-abc123", nil)
|
||||
c.Request.Header.Set("Authorization", "Bearer test-token")
|
||||
c.Params = gin.Params{{Key: "tai_id", Value: "tai-abc123"}}
|
||||
|
||||
HandleUnregister(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body = %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
|
||||
if _, ok := reg.Get("tai-abc123"); ok {
|
||||
t.Error("node should be removed after unregister")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUnregister_WrongOwner(t *testing.T) {
|
||||
teardown := setupTest()
|
||||
defer teardown()
|
||||
|
||||
reg := registry.Global()
|
||||
reg.Register(®istry.TaiNode{
|
||||
TaiID: "tai-other",
|
||||
Mode: "direct",
|
||||
Auth: registry.AuthInfo{ClientID: "different-client"},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("DELETE", "/tai-nodes/register/tai-other", nil)
|
||||
c.Request.Header.Set("Authorization", "Bearer test-token")
|
||||
c.Params = gin.Params{{Key: "tai_id", Value: "tai-other"}}
|
||||
|
||||
HandleUnregister(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUnregister_NotFound(t *testing.T) {
|
||||
teardown := setupTest()
|
||||
defer teardown()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("DELETE", "/tai-nodes/register/ghost", nil)
|
||||
c.Request.Header.Set("Authorization", "Bearer test-token")
|
||||
c.Params = gin.Params{{Key: "tai_id", Value: "ghost"}}
|
||||
|
||||
HandleUnregister(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
yaogrpc "github.com/yaoapp/yao/tai/grpc"
|
||||
)
|
||||
|
||||
// Build-time variables set via -ldflags.
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: yao-grpc <version|serve>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "version":
|
||||
fmt.Printf("yao-grpc %s (commit: %s, built: %s)\n", Version, Commit, BuildTime)
|
||||
case "serve":
|
||||
if err := serve(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\nUsage: yao-grpc <version|serve>\n", os.Args[1])
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// jsonrpcRequest is a minimal JSON-RPC 2.0 request.
|
||||
type jsonrpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// jsonrpcResponse is a minimal JSON-RPC 2.0 response.
|
||||
type jsonrpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *jsonrpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type jsonrpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func serve() error {
|
||||
client, err := yaogrpc.NewFromEnv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if sandboxID := os.Getenv("YAO_SANDBOX_ID"); sandboxID != "" {
|
||||
go yaogrpc.HeartbeatLoop(ctx, client, sandboxID)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 4*1024*1024), 4*1024*1024)
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var req jsonrpcRequest
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
encoder.Encode(jsonrpcResponse{
|
||||
JSONRPC: "2.0",
|
||||
Error: &jsonrpcError{Code: -32700, Message: "parse error"},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
resp := dispatch(ctx, client, &req)
|
||||
encoder.Encode(resp)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil && err != io.EOF {
|
||||
return fmt.Errorf("stdin read: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dispatch(ctx context.Context, client *yaogrpc.Client, req *jsonrpcRequest) jsonrpcResponse {
|
||||
base := jsonrpcResponse{JSONRPC: "2.0", ID: req.ID}
|
||||
|
||||
switch req.Method {
|
||||
case "run":
|
||||
return handleRun(ctx, client, req, base)
|
||||
case "shell":
|
||||
return handleShell(ctx, client, req, base)
|
||||
case "mcp/list_tools":
|
||||
return handleMCPListTools(ctx, client, req, base)
|
||||
case "mcp/call_tool":
|
||||
return handleMCPCallTool(ctx, client, req, base)
|
||||
case "mcp/list_resources":
|
||||
return handleMCPListResources(ctx, client, req, base)
|
||||
case "mcp/read_resource":
|
||||
return handleMCPReadResource(ctx, client, req, base)
|
||||
case "healthz":
|
||||
return handleHealthz(ctx, client, base)
|
||||
default:
|
||||
base.Error = &jsonrpcError{Code: -32601, Message: "method not found: " + req.Method}
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
type runParams struct {
|
||||
Process string `json:"process"`
|
||||
Args json.RawMessage `json:"args,omitempty"`
|
||||
Timeout int32 `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
func handleRun(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
|
||||
var p runParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
|
||||
return base
|
||||
}
|
||||
data, err := c.Run(ctx, p.Process, p.Args, p.Timeout)
|
||||
if err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
|
||||
return base
|
||||
}
|
||||
base.Result = data
|
||||
return base
|
||||
}
|
||||
|
||||
type shellParams struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Timeout int32 `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
func handleShell(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
|
||||
var p shellParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
|
||||
return base
|
||||
}
|
||||
resp, err := c.Shell(ctx, p.Command, p.Args, p.Env, p.Timeout)
|
||||
if err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
|
||||
return base
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
base.Result = data
|
||||
return base
|
||||
}
|
||||
|
||||
type mcpSessionParams struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
func handleMCPListTools(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
|
||||
var p mcpSessionParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
|
||||
return base
|
||||
}
|
||||
data, err := c.MCPListTools(ctx, p.SessionID)
|
||||
if err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
|
||||
return base
|
||||
}
|
||||
base.Result = data
|
||||
return base
|
||||
}
|
||||
|
||||
type mcpCallParams struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Tool string `json:"tool"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
func handleMCPCallTool(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
|
||||
var p mcpCallParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
|
||||
return base
|
||||
}
|
||||
data, err := c.MCPCallTool(ctx, p.SessionID, p.Tool, p.Arguments)
|
||||
if err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
|
||||
return base
|
||||
}
|
||||
base.Result = data
|
||||
return base
|
||||
}
|
||||
|
||||
func handleMCPListResources(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
|
||||
var p mcpSessionParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
|
||||
return base
|
||||
}
|
||||
data, err := c.MCPListResources(ctx, p.SessionID)
|
||||
if err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
|
||||
return base
|
||||
}
|
||||
base.Result = data
|
||||
return base
|
||||
}
|
||||
|
||||
type mcpReadParams struct {
|
||||
SessionID string `json:"session_id"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
func handleMCPReadResource(ctx context.Context, c *yaogrpc.Client, req *jsonrpcRequest, base jsonrpcResponse) jsonrpcResponse {
|
||||
var p mcpReadParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32602, Message: "invalid params: " + err.Error()}
|
||||
return base
|
||||
}
|
||||
data, err := c.MCPReadResource(ctx, p.SessionID, p.URI)
|
||||
if err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
|
||||
return base
|
||||
}
|
||||
base.Result = data
|
||||
return base
|
||||
}
|
||||
|
||||
func handleHealthz(ctx context.Context, c *yaogrpc.Client, base jsonrpcResponse) jsonrpcResponse {
|
||||
status, err := c.Healthz(ctx)
|
||||
if err != nil {
|
||||
base.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
|
||||
return base
|
||||
}
|
||||
data, _ := json.Marshal(map[string]string{"status": status})
|
||||
base.Result = data
|
||||
return base
|
||||
}
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
package grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
yaogrpc "github.com/yaoapp/yao/tai/grpc"
|
||||
)
|
||||
|
||||
// ── TokenManager unit tests ──────────────────────────────────────────────────
|
||||
|
||||
func TestTokenManager_AttachMetadata_WithAllFields(t *testing.T) {
|
||||
tm := yaogrpc.NewTokenManager("tok", "ref", "sb-1", "yao:9099")
|
||||
ctx := tm.AttachMetadata(context.Background())
|
||||
|
||||
md, ok := metadata.FromOutgoingContext(ctx)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Equal(t, []string{"Bearer tok"}, md.Get("authorization"))
|
||||
assert.Equal(t, []string{"ref"}, md.Get("x-refresh-token"))
|
||||
assert.Equal(t, []string{"sb-1"}, md.Get("x-sandbox-id"))
|
||||
assert.Equal(t, []string{"yao:9099"}, md.Get("x-grpc-upstream"))
|
||||
}
|
||||
|
||||
func TestTokenManager_AttachMetadata_DirectMode(t *testing.T) {
|
||||
tm := yaogrpc.NewTokenManager("tok", "ref", "sb-1", "")
|
||||
ctx := tm.AttachMetadata(context.Background())
|
||||
|
||||
md, ok := metadata.FromOutgoingContext(ctx)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Equal(t, []string{"Bearer tok"}, md.Get("authorization"))
|
||||
assert.Empty(t, md.Get("x-grpc-upstream"), "direct mode should not set x-grpc-upstream")
|
||||
}
|
||||
|
||||
func TestTokenManager_AttachMetadata_EmptyTokens(t *testing.T) {
|
||||
tm := yaogrpc.NewTokenManager("", "", "", "")
|
||||
ctx := tm.AttachMetadata(context.Background())
|
||||
|
||||
_, ok := metadata.FromOutgoingContext(ctx)
|
||||
assert.False(t, ok, "empty tokens should not produce metadata")
|
||||
}
|
||||
|
||||
func TestTokenManager_HandleResponseHeaders(t *testing.T) {
|
||||
tm := yaogrpc.NewTokenManager("old-tok", "old-ref", "", "")
|
||||
|
||||
tm.HandleResponseHeaders(metadata.New(map[string]string{
|
||||
"x-access-token": "new-tok",
|
||||
"x-refresh-token": "new-ref",
|
||||
}))
|
||||
|
||||
assert.Equal(t, "new-tok", tm.AccessToken())
|
||||
|
||||
ctx := tm.AttachMetadata(context.Background())
|
||||
md, _ := metadata.FromOutgoingContext(ctx)
|
||||
assert.Equal(t, []string{"Bearer new-tok"}, md.Get("authorization"))
|
||||
assert.Equal(t, []string{"new-ref"}, md.Get("x-refresh-token"))
|
||||
}
|
||||
|
||||
func TestTokenManager_HandleResponseHeaders_Nil(t *testing.T) {
|
||||
tm := yaogrpc.NewTokenManager("tok", "", "", "")
|
||||
tm.HandleResponseHeaders(nil)
|
||||
assert.Equal(t, "tok", tm.AccessToken())
|
||||
}
|
||||
|
||||
func TestTokenManager_HandleResponseHeaders_EmptyValues(t *testing.T) {
|
||||
tm := yaogrpc.NewTokenManager("tok", "ref", "", "")
|
||||
tm.HandleResponseHeaders(metadata.New(map[string]string{
|
||||
"x-access-token": "",
|
||||
}))
|
||||
assert.Equal(t, "tok", tm.AccessToken(), "empty header should not overwrite")
|
||||
}
|
||||
|
||||
func TestTokenManager_IsTaiMode(t *testing.T) {
|
||||
tmDirect := yaogrpc.NewTokenManager("tok", "", "", "")
|
||||
assert.False(t, tmDirect.IsTaiMode())
|
||||
|
||||
tmTai := yaogrpc.NewTokenManager("tok", "", "", "tai:9100")
|
||||
assert.True(t, tmTai.IsTaiMode())
|
||||
}
|
||||
|
||||
func TestTokenManager_NewFromEnv_MissingUpstream(t *testing.T) {
|
||||
t.Setenv("YAO_GRPC_TAI", "enable")
|
||||
t.Setenv("YAO_GRPC_UPSTREAM", "")
|
||||
t.Setenv("YAO_TOKEN", "tok")
|
||||
|
||||
_, err := yaogrpc.NewTokenManagerFromEnv()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "YAO_GRPC_UPSTREAM")
|
||||
}
|
||||
|
||||
func TestTokenManager_NewFromEnv_TaiEnabled(t *testing.T) {
|
||||
t.Setenv("YAO_GRPC_TAI", "enable")
|
||||
t.Setenv("YAO_GRPC_UPSTREAM", "yao:9099")
|
||||
t.Setenv("YAO_TOKEN", "my-token")
|
||||
t.Setenv("YAO_REFRESH_TOKEN", "my-refresh")
|
||||
t.Setenv("YAO_SANDBOX_ID", "sb-42")
|
||||
|
||||
tm, err := yaogrpc.NewTokenManagerFromEnv()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, tm.IsTaiMode())
|
||||
assert.Equal(t, "my-token", tm.AccessToken())
|
||||
}
|
||||
|
||||
func TestTokenManager_NewFromEnv_DirectMode(t *testing.T) {
|
||||
t.Setenv("YAO_GRPC_TAI", "")
|
||||
t.Setenv("YAO_GRPC_UPSTREAM", "")
|
||||
t.Setenv("YAO_TOKEN", "tok")
|
||||
|
||||
tm, err := yaogrpc.NewTokenManagerFromEnv()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, tm.IsTaiMode())
|
||||
}
|
||||
|
||||
// ── Dial tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestNewFromEnv_MissingAddr(t *testing.T) {
|
||||
t.Setenv("YAO_GRPC_ADDR", "")
|
||||
_, err := yaogrpc.NewFromEnv()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "YAO_GRPC_ADDR")
|
||||
}
|
||||
|
||||
func TestNewFromEnv_Success(t *testing.T) {
|
||||
t.Setenv("YAO_GRPC_ADDR", "127.0.0.1:9099")
|
||||
t.Setenv("YAO_TOKEN", "test-token")
|
||||
t.Setenv("YAO_REFRESH_TOKEN", "test-refresh")
|
||||
t.Setenv("YAO_SANDBOX_ID", "sb-1")
|
||||
t.Setenv("YAO_GRPC_TAI", "")
|
||||
|
||||
c, err := yaogrpc.NewFromEnv()
|
||||
require.NoError(t, err)
|
||||
defer c.Close()
|
||||
|
||||
assert.NotNil(t, c.Conn())
|
||||
assert.Equal(t, "test-token", c.TokenManager().AccessToken())
|
||||
}
|
||||
|
||||
func TestNewFromEnv_TaiMode_MissingUpstream(t *testing.T) {
|
||||
t.Setenv("YAO_GRPC_ADDR", "tai:9100")
|
||||
t.Setenv("YAO_GRPC_TAI", "enable")
|
||||
t.Setenv("YAO_GRPC_UPSTREAM", "")
|
||||
|
||||
_, err := yaogrpc.NewFromEnv()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "YAO_GRPC_UPSTREAM")
|
||||
}
|
||||
|
||||
func TestDial_WithNilTokenManager(t *testing.T) {
|
||||
c, err := yaogrpc.Dial("127.0.0.1:0", nil)
|
||||
require.NoError(t, err)
|
||||
defer c.Close()
|
||||
|
||||
assert.NotNil(t, c.Conn())
|
||||
assert.Nil(t, c.TokenManager())
|
||||
}
|
||||
|
||||
func TestDial_WithTokenManager(t *testing.T) {
|
||||
tm := yaogrpc.NewTokenManager("tok", "", "", "")
|
||||
c, err := yaogrpc.Dial("127.0.0.1:0", tm)
|
||||
require.NoError(t, err)
|
||||
defer c.Close()
|
||||
|
||||
assert.NotNil(t, c.TokenManager())
|
||||
assert.False(t, c.TokenManager().IsTaiMode())
|
||||
}
|
||||
|
||||
func TestDial_PassthroughPrefix_BareAddress(t *testing.T) {
|
||||
c, err := yaogrpc.Dial("host.docker.internal:9099", nil)
|
||||
require.NoError(t, err)
|
||||
defer c.Close()
|
||||
|
||||
assert.Equal(t, "passthrough:///host.docker.internal:9099", c.Conn().Target())
|
||||
}
|
||||
|
||||
func TestDial_PassthroughPrefix_IPAddress(t *testing.T) {
|
||||
c, err := yaogrpc.Dial("192.168.1.100:9100", nil)
|
||||
require.NoError(t, err)
|
||||
defer c.Close()
|
||||
|
||||
assert.Equal(t, "passthrough:///192.168.1.100:9100", c.Conn().Target())
|
||||
}
|
||||
|
||||
func TestDial_PassthroughPrefix_PreservesExistingScheme(t *testing.T) {
|
||||
tests := []struct {
|
||||
addr string
|
||||
target string
|
||||
}{
|
||||
{"dns:///myhost:9099", "dns:///myhost:9099"},
|
||||
{"passthrough:///127.0.0.1:9099", "passthrough:///127.0.0.1:9099"},
|
||||
{"unix:///var/run/grpc.sock", "unix:///var/run/grpc.sock"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.addr, func(t *testing.T) {
|
||||
c, err := yaogrpc.Dial(tt.addr, nil)
|
||||
require.NoError(t, err)
|
||||
defer c.Close()
|
||||
assert.Equal(t, tt.target, c.Conn().Target())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Close_Nil(t *testing.T) {
|
||||
c := &yaogrpc.Client{}
|
||||
assert.NoError(t, c.Close())
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/grpc/pb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func TestCountUserProcesses(t *testing.T) {
|
||||
n := countUserProcesses()
|
||||
if n < 0 {
|
||||
t.Errorf("countUserProcesses() = %d, want >= 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleResources(t *testing.T) {
|
||||
cpu, mem := sampleResources()
|
||||
if cpu < 0 || mem < 0 {
|
||||
t.Errorf("sampleResources() = (%d, %d), want non-negative", cpu, mem)
|
||||
}
|
||||
}
|
||||
|
||||
// ── HeartbeatLoop tests with mock gRPC server ───────────────────────────────
|
||||
|
||||
type mockYaoServer struct {
|
||||
pb.UnimplementedYaoServer
|
||||
calls atomic.Int32
|
||||
action string
|
||||
}
|
||||
|
||||
func (m *mockYaoServer) Heartbeat(_ context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) {
|
||||
m.calls.Add(1)
|
||||
return &pb.HeartbeatResponse{Action: m.action}, nil
|
||||
}
|
||||
|
||||
func startMockServer(t *testing.T, srv *mockYaoServer) (addr string, stop func()) {
|
||||
t.Helper()
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := grpc.NewServer()
|
||||
pb.RegisterYaoServer(s, srv)
|
||||
go s.Serve(lis)
|
||||
return lis.Addr().String(), s.Stop
|
||||
}
|
||||
|
||||
func dialClient(t *testing.T, addr string) *Client {
|
||||
t.Helper()
|
||||
c, err := Dial(addr, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestHeartbeatLoop_SendsHeartbeats(t *testing.T) {
|
||||
mock := &mockYaoServer{action: "ok"}
|
||||
addr, stop := startMockServer(t, mock)
|
||||
defer stop()
|
||||
|
||||
client := dialClient(t, addr)
|
||||
defer client.Close()
|
||||
|
||||
t.Setenv("YAO_HEARTBEAT_INTERVAL", "50ms")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
HeartbeatLoop(ctx, client, "sb-test")
|
||||
|
||||
calls := mock.calls.Load()
|
||||
if calls < 2 {
|
||||
t.Errorf("expected at least 2 heartbeat calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatLoop_ShutdownAction(t *testing.T) {
|
||||
mock := &mockYaoServer{action: "shutdown"}
|
||||
addr, stop := startMockServer(t, mock)
|
||||
defer stop()
|
||||
|
||||
client := dialClient(t, addr)
|
||||
defer client.Close()
|
||||
|
||||
action, err := client.Heartbeat(context.Background(), "sb-shutdown", 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Heartbeat: %v", err)
|
||||
}
|
||||
if action != "shutdown" {
|
||||
t.Errorf("action = %q, want %q", action, "shutdown")
|
||||
}
|
||||
if mock.calls.Load() != 1 {
|
||||
t.Errorf("expected 1 call, got %d", mock.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatLoop_ContextCancelStops(t *testing.T) {
|
||||
mock := &mockYaoServer{action: "ok"}
|
||||
addr, stop := startMockServer(t, mock)
|
||||
defer stop()
|
||||
|
||||
client := dialClient(t, addr)
|
||||
defer client.Close()
|
||||
|
||||
t.Setenv("YAO_HEARTBEAT_INTERVAL", "5s")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
HeartbeatLoop(ctx, client, "sb-cancel")
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("HeartbeatLoop did not stop after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatLoop_IntervalParsing(t *testing.T) {
|
||||
mock := &mockYaoServer{action: "ok"}
|
||||
addr, stop := startMockServer(t, mock)
|
||||
defer stop()
|
||||
|
||||
client := dialClient(t, addr)
|
||||
defer client.Close()
|
||||
|
||||
t.Setenv("YAO_HEARTBEAT_INTERVAL", "30ms")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
HeartbeatLoop(ctx, client, "sb-interval")
|
||||
|
||||
calls := mock.calls.Load()
|
||||
if calls < 3 {
|
||||
t.Errorf("with 30ms interval over 150ms, expected >= 3 calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatLoop_InvalidIntervalUsesDefault(t *testing.T) {
|
||||
mock := &mockYaoServer{action: "ok"}
|
||||
addr, stop := startMockServer(t, mock)
|
||||
defer stop()
|
||||
|
||||
client := dialClient(t, addr)
|
||||
defer client.Close()
|
||||
|
||||
t.Setenv("YAO_HEARTBEAT_INTERVAL", "not-a-duration")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
HeartbeatLoop(ctx, client, "sb-invalid")
|
||||
|
||||
if mock.calls.Load() > 0 {
|
||||
t.Error("with default 10s interval and 100ms timeout, expected 0 calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHeartbeat_ReturnsAction(t *testing.T) {
|
||||
mock := &mockYaoServer{action: "ok"}
|
||||
addr, stop := startMockServer(t, mock)
|
||||
defer stop()
|
||||
|
||||
conn, err := grpc.NewClient("passthrough:///"+addr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
c := &Client{conn: conn, svc: pb.NewYaoClient(conn)}
|
||||
action, err := c.Heartbeat(context.Background(), "sb-1", 50, 2048, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Heartbeat: %v", err)
|
||||
}
|
||||
if action != "ok" {
|
||||
t.Errorf("action = %q, want %q", action, "ok")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
package grpc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yaoapp/yao/grpc/tests/testutils"
|
||||
yaogrpc "github.com/yaoapp/yao/tai/grpc"
|
||||
)
|
||||
|
||||
// Integration tests that start a real Yao gRPC server and test the tai/grpc
|
||||
// client through the full interceptor -> handler chain.
|
||||
|
||||
func setupClient(t *testing.T, scopes ...string) *yaogrpc.Client {
|
||||
t.Helper()
|
||||
|
||||
conn := testutils.Prepare(t)
|
||||
t.Cleanup(func() {
|
||||
conn.Close()
|
||||
testutils.Clean()
|
||||
})
|
||||
|
||||
addr := testutils.Addr()
|
||||
token := testutils.ObtainAccessToken(t, scopes...)
|
||||
refreshToken := testutils.ObtainRefreshToken(t, scopes...)
|
||||
|
||||
tm := yaogrpc.NewTokenManager(token, refreshToken, "test-sandbox", "")
|
||||
client, err := yaogrpc.Dial(addr, tm)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { client.Close() })
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// ── Healthz ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestIntegration_Healthz(t *testing.T) {
|
||||
conn := testutils.Prepare(t)
|
||||
defer func() {
|
||||
conn.Close()
|
||||
testutils.Clean()
|
||||
}()
|
||||
|
||||
addr := testutils.Addr()
|
||||
tm := yaogrpc.NewTokenManager("", "", "", "")
|
||||
client, err := yaogrpc.Dial(addr, tm)
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
status, err := client.Healthz(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ok", status)
|
||||
}
|
||||
|
||||
// ── Run ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestIntegration_Run_Ping(t *testing.T) {
|
||||
client := setupClient(t, "grpc:run")
|
||||
|
||||
data, err := client.Run(context.Background(), "utils.app.Ping", nil, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
}
|
||||
|
||||
func TestIntegration_Run_InvalidProcess(t *testing.T) {
|
||||
client := setupClient(t, "grpc:run")
|
||||
|
||||
_, err := client.Run(context.Background(), "nonexistent.process", nil, 0)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIntegration_Run_WithArgs(t *testing.T) {
|
||||
client := setupClient(t, "grpc:run")
|
||||
|
||||
args, _ := json.Marshal([]any{"hello", "world"})
|
||||
data, err := client.Run(context.Background(), "utils.app.Ping", args, 5)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
}
|
||||
|
||||
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestIntegration_Shell_Echo(t *testing.T) {
|
||||
client := setupClient(t, "grpc:shell")
|
||||
|
||||
resp, err := client.Shell(context.Background(), "echo", []string{"hello"}, nil, 5)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, int32(0), resp.ExitCode)
|
||||
assert.Contains(t, string(resp.Stdout), "hello")
|
||||
}
|
||||
|
||||
func TestIntegration_Shell_NotFound(t *testing.T) {
|
||||
client := setupClient(t, "grpc:shell")
|
||||
|
||||
_, err := client.Shell(context.Background(), "nonexistent-command-xyz", nil, nil, 5)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// ── MCP ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestIntegration_MCPListTools(t *testing.T) {
|
||||
client := setupClient(t, "grpc:mcp")
|
||||
|
||||
data, err := client.MCPListTools(context.Background(), "echo")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
|
||||
var tools []any
|
||||
assert.NoError(t, json.Unmarshal(data, &tools))
|
||||
assert.Greater(t, len(tools), 0)
|
||||
}
|
||||
|
||||
func TestIntegration_MCPCallTool(t *testing.T) {
|
||||
client := setupClient(t, "grpc:mcp")
|
||||
|
||||
args, _ := json.Marshal(map[string]string{"message": "hi"})
|
||||
data, err := client.MCPCallTool(context.Background(), "echo", "ping", args)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
}
|
||||
|
||||
func TestIntegration_MCPListResources(t *testing.T) {
|
||||
client := setupClient(t, "grpc:mcp")
|
||||
|
||||
data, err := client.MCPListResources(context.Background(), "echo")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
}
|
||||
|
||||
func TestIntegration_MCPReadResource(t *testing.T) {
|
||||
client := setupClient(t, "grpc:mcp")
|
||||
|
||||
data, err := client.MCPReadResource(context.Background(), "echo", "echo://info")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
}
|
||||
|
||||
// ── API ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestIntegration_API_Proxy(t *testing.T) {
|
||||
client := setupClient(t, "grpc:run", "grpc:mcp")
|
||||
|
||||
resp, err := client.API(context.Background(), "GET", "/api/__yao/app/setting", nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
t.Logf("API proxy status: %d", resp.Status)
|
||||
}
|
||||
|
||||
// ── LLM ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestIntegration_ChatCompletions_InvalidConnector(t *testing.T) {
|
||||
client := setupClient(t, "grpc:llm")
|
||||
|
||||
messages, _ := json.Marshal([]map[string]string{
|
||||
{"role": "user", "content": "test"},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.ChatCompletions(ctx, "nonexistent-connector", messages, nil)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIntegration_ChatCompletionsStream_InvalidConnector(t *testing.T) {
|
||||
client := setupClient(t, "grpc:llm")
|
||||
|
||||
messages, _ := json.Marshal([]map[string]string{
|
||||
{"role": "user", "content": "test"},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := client.ChatCompletionsStream(ctx, "nonexistent-connector", messages, nil,
|
||||
func(data []byte, done bool) error { return nil })
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIntegration_ChatCompletions_EmptyMessages(t *testing.T) {
|
||||
client := setupClient(t, "grpc:llm")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.ChatCompletions(ctx, "default", nil, nil)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// ── Agent ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestIntegration_AgentStream_InvalidRobot(t *testing.T) {
|
||||
client := setupClient(t, "grpc:agent")
|
||||
|
||||
messages, _ := json.Marshal([]map[string]string{
|
||||
{"role": "user", "content": "hello"},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := client.AgentStream(ctx, "nonexistent-robot-xyz", messages, nil,
|
||||
func(data []byte, done bool) error { return nil })
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// ── Unauthenticated ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestIntegration_Run_NoToken(t *testing.T) {
|
||||
conn := testutils.Prepare(t)
|
||||
defer func() {
|
||||
conn.Close()
|
||||
testutils.Clean()
|
||||
}()
|
||||
|
||||
addr := testutils.Addr()
|
||||
tm := yaogrpc.NewTokenManager("", "", "", "")
|
||||
client, err := yaogrpc.Dial(addr, tm)
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
_, err = client.Run(context.Background(), "utils.app.Ping", nil, 0)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Unauthenticated")
|
||||
}
|
||||
|
||||
// ── Token Refresh via interceptor ────────────────────────────────────────────
|
||||
|
||||
func TestIntegration_TokenRefresh(t *testing.T) {
|
||||
conn := testutils.Prepare(t)
|
||||
defer func() {
|
||||
conn.Close()
|
||||
testutils.Clean()
|
||||
}()
|
||||
|
||||
addr := testutils.Addr()
|
||||
scopes := []string{"grpc:run"}
|
||||
|
||||
expiredToken := testutils.ObtainExpiredAccessToken(t, scopes...)
|
||||
refreshToken := testutils.ObtainRefreshToken(t, scopes...)
|
||||
|
||||
tm := yaogrpc.NewTokenManager(expiredToken, refreshToken, "sb-test", "")
|
||||
client, err := yaogrpc.Dial(addr, tm)
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
data, err := client.Run(context.Background(), "utils.app.Ping", nil, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
|
||||
newToken := tm.AccessToken()
|
||||
if newToken != expiredToken {
|
||||
t.Logf("token was refreshed: old=%s... new=%s...", expiredToken[:20], newToken[:20])
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Relay mode tests — client → Tai (:9100) → x-grpc-upstream → Yao gRPC
|
||||
// Requires TAI_TEST_GRPC env var (e.g. 127.0.0.1:9100) and a running Tai server.
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func setupRelayClient(t *testing.T, scopes ...string) *yaogrpc.Client {
|
||||
t.Helper()
|
||||
|
||||
taiAddr := os.Getenv("TAI_TEST_GRPC")
|
||||
if taiAddr == "" {
|
||||
t.Skip("TAI_TEST_GRPC not set, skipping relay mode test")
|
||||
}
|
||||
|
||||
conn := testutils.Prepare(t)
|
||||
t.Cleanup(func() {
|
||||
conn.Close()
|
||||
testutils.Clean()
|
||||
})
|
||||
|
||||
yaoAddr := testutils.RelayAddr()
|
||||
token := testutils.ObtainAccessToken(t, scopes...)
|
||||
refreshToken := testutils.ObtainRefreshToken(t, scopes...)
|
||||
|
||||
// upstream = Yao gRPC address reachable from the Tai container
|
||||
tm := yaogrpc.NewTokenManager(token, refreshToken, "relay-sandbox", yaoAddr)
|
||||
client, err := yaogrpc.Dial(taiAddr, tm)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { client.Close() })
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func TestRelay_Healthz(t *testing.T) {
|
||||
taiAddr := os.Getenv("TAI_TEST_GRPC")
|
||||
if taiAddr == "" {
|
||||
t.Skip("TAI_TEST_GRPC not set")
|
||||
}
|
||||
|
||||
conn := testutils.Prepare(t)
|
||||
defer func() {
|
||||
conn.Close()
|
||||
testutils.Clean()
|
||||
}()
|
||||
|
||||
yaoAddr := testutils.RelayAddr()
|
||||
tm := yaogrpc.NewTokenManager("", "", "", yaoAddr)
|
||||
client, err := yaogrpc.Dial(taiAddr, tm)
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
status, err := client.Healthz(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", status)
|
||||
}
|
||||
|
||||
func TestRelay_Run_Ping(t *testing.T) {
|
||||
client := setupRelayClient(t, "grpc:run")
|
||||
|
||||
data, err := client.Run(context.Background(), "utils.app.Ping", nil, 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, data)
|
||||
t.Logf("relay Run result: %s", string(data))
|
||||
}
|
||||
|
||||
func TestRelay_Run_InvalidProcess(t *testing.T) {
|
||||
client := setupRelayClient(t, "grpc:run")
|
||||
|
||||
_, err := client.Run(context.Background(), "nonexistent.process", nil, 0)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRelay_Shell_Echo(t *testing.T) {
|
||||
client := setupRelayClient(t, "grpc:shell")
|
||||
|
||||
resp, err := client.Shell(context.Background(), "echo", []string{"relay-test"}, nil, 5)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, int32(0), resp.ExitCode)
|
||||
assert.Contains(t, string(resp.Stdout), "relay-test")
|
||||
}
|
||||
|
||||
func TestRelay_MCPListTools(t *testing.T) {
|
||||
client := setupRelayClient(t, "grpc:mcp")
|
||||
|
||||
data, err := client.MCPListTools(context.Background(), "echo")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
|
||||
var tools []any
|
||||
assert.NoError(t, json.Unmarshal(data, &tools))
|
||||
assert.Greater(t, len(tools), 0)
|
||||
}
|
||||
|
||||
func TestRelay_MCPCallTool(t *testing.T) {
|
||||
client := setupRelayClient(t, "grpc:mcp")
|
||||
|
||||
args, _ := json.Marshal(map[string]string{"message": "relay"})
|
||||
data, err := client.MCPCallTool(context.Background(), "echo", "ping", args)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
}
|
||||
|
||||
func TestRelay_Run_NoToken(t *testing.T) {
|
||||
taiAddr := os.Getenv("TAI_TEST_GRPC")
|
||||
if taiAddr == "" {
|
||||
t.Skip("TAI_TEST_GRPC not set")
|
||||
}
|
||||
|
||||
conn := testutils.Prepare(t)
|
||||
defer func() {
|
||||
conn.Close()
|
||||
testutils.Clean()
|
||||
}()
|
||||
|
||||
yaoAddr := testutils.RelayAddr()
|
||||
tm := yaogrpc.NewTokenManager("", "", "", yaoAddr)
|
||||
client, err := yaogrpc.Dial(taiAddr, tm)
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
_, err = client.Run(context.Background(), "utils.app.Ping", nil, 0)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Unauthenticated")
|
||||
}
|
||||
|
||||
func TestRelay_TokenRefresh(t *testing.T) {
|
||||
taiAddr := os.Getenv("TAI_TEST_GRPC")
|
||||
if taiAddr == "" {
|
||||
t.Skip("TAI_TEST_GRPC not set")
|
||||
}
|
||||
|
||||
conn := testutils.Prepare(t)
|
||||
defer func() {
|
||||
conn.Close()
|
||||
testutils.Clean()
|
||||
}()
|
||||
|
||||
yaoAddr := testutils.RelayAddr()
|
||||
scopes := []string{"grpc:run"}
|
||||
|
||||
expiredToken := testutils.ObtainExpiredAccessToken(t, scopes...)
|
||||
refreshToken := testutils.ObtainRefreshToken(t, scopes...)
|
||||
|
||||
tm := yaogrpc.NewTokenManager(expiredToken, refreshToken, "relay-sb", yaoAddr)
|
||||
client, err := yaogrpc.Dial(taiAddr, tm)
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
data, err := client.Run(context.Background(), "utils.app.Ping", nil, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
|
||||
newToken := tm.AccessToken()
|
||||
if newToken != expiredToken {
|
||||
t.Logf("relay token refreshed: old=%s... new=%s...", expiredToken[:20], newToken[:20])
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package grpc
|
||||
package tai
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -14,8 +14,8 @@ import (
|
|||
const defaultHeartbeatInterval = 10 * time.Second
|
||||
|
||||
// HeartbeatLoop sends periodic heartbeats to the Yao gRPC server.
|
||||
// It runs until ctx is cancelled. The sandboxID comes from YAO_SANDBOX_ID.
|
||||
func HeartbeatLoop(ctx context.Context, client *Client, sandboxID string) {
|
||||
// It runs until ctx is cancelled.
|
||||
func HeartbeatLoop(ctx context.Context, client *YaoClient, sandboxID string) {
|
||||
interval := defaultHeartbeatInterval
|
||||
if s := os.Getenv("YAO_HEARTBEAT_INTERVAL"); s != "" {
|
||||
if d, err := time.ParseDuration(s); err == nil && d > 0 {
|
||||
|
|
@ -38,7 +38,7 @@ func HeartbeatLoop(ctx context.Context, client *Client, sandboxID string) {
|
|||
continue
|
||||
}
|
||||
if action == "shutdown" {
|
||||
fmt.Fprintf(os.Stderr, "yao-grpc: received shutdown signal\n")
|
||||
fmt.Fprintf(os.Stderr, "tai: received shutdown signal\n")
|
||||
p, _ := os.FindProcess(os.Getpid())
|
||||
p.Signal(os.Interrupt)
|
||||
return
|
||||
|
|
@ -47,12 +47,10 @@ func HeartbeatLoop(ctx context.Context, client *Client, sandboxID string) {
|
|||
}
|
||||
}
|
||||
|
||||
// countUserProcesses counts running processes owned by the current user.
|
||||
func countUserProcesses() int32 {
|
||||
if runtime.GOOS != "linux" {
|
||||
return 0
|
||||
}
|
||||
|
||||
out, err := exec.Command("sh", "-c", "ps -e --no-headers | wc -l").Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
|
|
@ -61,17 +59,14 @@ func countUserProcesses() int32 {
|
|||
return int32(n)
|
||||
}
|
||||
|
||||
// sampleResources reads basic CPU/memory stats from /proc (Linux only).
|
||||
func sampleResources() (cpuPercent int32, memBytes int64) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("/sys/fs/cgroup/memory.current")
|
||||
if err == nil {
|
||||
mem, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||
memBytes = mem
|
||||
}
|
||||
|
||||
return 0, memBytes
|
||||
}
|
||||
|
|
@ -13,6 +13,15 @@ import (
|
|||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// SystemInfo describes the host machine running Tai.
|
||||
type SystemInfo struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Hostname string `json:"hostname"`
|
||||
NumCPU int `json:"num_cpu"`
|
||||
TotalMem int64 `json:"total_mem,omitempty"`
|
||||
}
|
||||
|
||||
// TaiNode represents a registered Tai instance (direct or tunnel).
|
||||
// Internal use only; external callers receive NodeSnapshot via Get()/List().
|
||||
type TaiNode struct {
|
||||
|
|
@ -20,6 +29,7 @@ type TaiNode struct {
|
|||
MachineID string
|
||||
Version string
|
||||
Auth AuthInfo
|
||||
System SystemInfo
|
||||
Mode string // "direct" | "tunnel"
|
||||
Addr string // direct mode: "tai-host"; tunnel mode: empty
|
||||
YaoBase string // Yao server base URL reported by Tai (tunnel mode)
|
||||
|
|
@ -43,6 +53,7 @@ type NodeSnapshot struct {
|
|||
MachineID string
|
||||
Version string
|
||||
Auth AuthInfo
|
||||
System SystemInfo
|
||||
Mode string
|
||||
Addr string
|
||||
YaoBase string
|
||||
|
|
@ -65,7 +76,8 @@ func (n *TaiNode) snapshot() NodeSnapshot {
|
|||
}
|
||||
return NodeSnapshot{
|
||||
TaiID: n.TaiID, MachineID: n.MachineID, Version: n.Version,
|
||||
Auth: n.Auth, Mode: n.Mode, Addr: n.Addr, YaoBase: n.YaoBase,
|
||||
Auth: n.Auth, System: n.System,
|
||||
Mode: n.Mode, Addr: n.Addr, YaoBase: n.YaoBase,
|
||||
Ports: ports, Capabilities: caps,
|
||||
Status: n.Status, ConnectedAt: n.ConnectedAt, LastPing: n.LastPing,
|
||||
PoolName: n.PoolName,
|
||||
|
|
@ -222,6 +234,66 @@ func (r *Registry) UpdatePing(taiID string) {
|
|||
}
|
||||
}
|
||||
|
||||
// ListByTeam returns snapshots of all nodes belonging to the given team.
|
||||
func (r *Registry) ListByTeam(teamID string) []NodeSnapshot {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var result []NodeSnapshot
|
||||
for _, n := range r.nodes {
|
||||
if n.Auth.TeamID == teamID {
|
||||
result = append(result, n.snapshot())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// StartHealthCheck runs a background goroutine that periodically checks
|
||||
// direct-mode nodes for heartbeat timeout. Nodes whose LastPing exceeds
|
||||
// timeout are marked offline. Nodes that remain offline longer than
|
||||
// cleanupAfter are automatically unregistered.
|
||||
// The goroutine stops when ctx.Done() is closed.
|
||||
func (r *Registry) StartHealthCheck(done <-chan struct{}, interval, timeout, cleanupAfter time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.checkHealth(timeout, cleanupAfter)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *Registry) checkHealth(timeout, cleanupAfter time.Duration) {
|
||||
now := time.Now()
|
||||
var toRemove []string
|
||||
|
||||
r.mu.Lock()
|
||||
for id, n := range r.nodes {
|
||||
if n.Mode != "direct" {
|
||||
continue
|
||||
}
|
||||
elapsed := now.Sub(n.LastPing)
|
||||
if n.Status == "online" && elapsed > timeout {
|
||||
n.Status = "offline"
|
||||
r.logger.Warn("tai node offline (heartbeat timeout)",
|
||||
"tai_id", id, "last_ping", n.LastPing)
|
||||
}
|
||||
if n.Status == "offline" && elapsed > timeout+cleanupAfter {
|
||||
toRemove = append(toRemove, id)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, id := range toRemove {
|
||||
r.logger.Info("tai node auto-unregistered (offline too long)", "tai_id", id)
|
||||
r.Unregister(id)
|
||||
}
|
||||
}
|
||||
|
||||
// RequestChannel sends an "open" command to a tunnel-connected Tai via its
|
||||
// control channel. Returns a channel_id that Tai will use to connect back.
|
||||
// Blocks until the data channel is established or timeout.
|
||||
|
|
|
|||
|
|
@ -475,6 +475,135 @@ func newWSServer(handler func(*websocket.Conn)) *httptest.Server {
|
|||
}))
|
||||
}
|
||||
|
||||
func TestRegister_SystemInfo(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
r.Register(&TaiNode{
|
||||
TaiID: "tai-001",
|
||||
System: SystemInfo{
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
Hostname: "docker-host-01",
|
||||
NumCPU: 16,
|
||||
},
|
||||
})
|
||||
|
||||
snap, ok := r.Get("tai-001")
|
||||
if !ok {
|
||||
t.Fatal("node not found")
|
||||
}
|
||||
if snap.System.OS != "linux" {
|
||||
t.Errorf("System.OS = %q, want linux", snap.System.OS)
|
||||
}
|
||||
if snap.System.Arch != "amd64" {
|
||||
t.Errorf("System.Arch = %q, want amd64", snap.System.Arch)
|
||||
}
|
||||
if snap.System.Hostname != "docker-host-01" {
|
||||
t.Errorf("System.Hostname = %q, want docker-host-01", snap.System.Hostname)
|
||||
}
|
||||
if snap.System.NumCPU != 16 {
|
||||
t.Errorf("System.NumCPU = %d, want 16", snap.System.NumCPU)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListByTeam(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
r.Register(&TaiNode{TaiID: "tai-a", Auth: AuthInfo{TeamID: "team-dev"}})
|
||||
r.Register(&TaiNode{TaiID: "tai-b", Auth: AuthInfo{TeamID: "team-dev"}})
|
||||
r.Register(&TaiNode{TaiID: "tai-c", Auth: AuthInfo{TeamID: "team-ops"}})
|
||||
|
||||
devNodes := r.ListByTeam("team-dev")
|
||||
if len(devNodes) != 2 {
|
||||
t.Errorf("ListByTeam(team-dev) = %d nodes, want 2", len(devNodes))
|
||||
}
|
||||
|
||||
opsNodes := r.ListByTeam("team-ops")
|
||||
if len(opsNodes) != 1 {
|
||||
t.Errorf("ListByTeam(team-ops) = %d nodes, want 1", len(opsNodes))
|
||||
}
|
||||
|
||||
empty := r.ListByTeam("team-ghost")
|
||||
if len(empty) != 0 {
|
||||
t.Errorf("ListByTeam(team-ghost) = %d nodes, want 0", len(empty))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartHealthCheck_MarkOffline(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
r.Register(&TaiNode{TaiID: "tai-direct", Mode: "direct"})
|
||||
r.Register(&TaiNode{TaiID: "tai-tunnel", Mode: "tunnel"})
|
||||
|
||||
// Manually set LastPing to the past for the direct node.
|
||||
r.mu.Lock()
|
||||
r.nodes["tai-direct"].LastPing = time.Now().Add(-5 * time.Second)
|
||||
r.mu.Unlock()
|
||||
|
||||
done := make(chan struct{})
|
||||
r.StartHealthCheck(done, 50*time.Millisecond, 2*time.Second, 10*time.Minute)
|
||||
defer close(done)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
snap, ok := r.Get("tai-direct")
|
||||
if !ok {
|
||||
t.Fatal("direct node should still exist")
|
||||
}
|
||||
if snap.Status != "offline" {
|
||||
t.Errorf("direct node Status = %q, want offline", snap.Status)
|
||||
}
|
||||
|
||||
// Tunnel nodes should not be affected.
|
||||
snap2, ok := r.Get("tai-tunnel")
|
||||
if !ok {
|
||||
t.Fatal("tunnel node should still exist")
|
||||
}
|
||||
if snap2.Status != "online" {
|
||||
t.Errorf("tunnel node Status = %q, want online", snap2.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartHealthCheck_AutoCleanup(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
r.Register(&TaiNode{TaiID: "tai-stale", Mode: "direct"})
|
||||
|
||||
// Set LastPing far in the past so it exceeds both timeout and cleanupAfter.
|
||||
r.mu.Lock()
|
||||
r.nodes["tai-stale"].LastPing = time.Now().Add(-1 * time.Hour)
|
||||
r.mu.Unlock()
|
||||
|
||||
done := make(chan struct{})
|
||||
r.StartHealthCheck(done, 50*time.Millisecond, 1*time.Second, 1*time.Second)
|
||||
defer close(done)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if _, ok := r.Get("tai-stale"); ok {
|
||||
t.Error("stale node should have been auto-unregistered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartHealthCheck_PingKeepsAlive(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
r.Register(&TaiNode{TaiID: "tai-alive", Mode: "direct"})
|
||||
|
||||
done := make(chan struct{})
|
||||
r.StartHealthCheck(done, 50*time.Millisecond, 2*time.Second, 10*time.Minute)
|
||||
defer close(done)
|
||||
|
||||
// Continuously ping to keep the node alive.
|
||||
for i := 0; i < 4; i++ {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
r.UpdatePing("tai-alive")
|
||||
}
|
||||
|
||||
snap, ok := r.Get("tai-alive")
|
||||
if !ok {
|
||||
t.Fatal("node should still exist")
|
||||
}
|
||||
if snap.Status != "online" {
|
||||
t.Errorf("Status = %q, want online", snap.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeSnapshot_AuthInfo(t *testing.T) {
|
||||
r := newTestRegistry()
|
||||
r.Register(&TaiNode{
|
||||
|
|
|
|||
17
tai/token.go
Normal file
17
tai/token.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package tai
|
||||
|
||||
import grpcclient "github.com/yaoapp/yao/grpc/client"
|
||||
|
||||
// TokenManager is an alias for grpc/client.TokenManager.
|
||||
// New code should use grpc/client.TokenManager directly.
|
||||
type TokenManager = grpcclient.TokenManager
|
||||
|
||||
// NewTokenManagerFromEnv creates a TokenManager from environment variables.
|
||||
func NewTokenManagerFromEnv() (*TokenManager, error) {
|
||||
return grpcclient.NewTokenManagerFromEnv()
|
||||
}
|
||||
|
||||
// NewTokenManager creates a TokenManager with explicit values.
|
||||
func NewTokenManager(accessToken, refreshToken, sandboxID string) *TokenManager {
|
||||
return grpcclient.NewTokenManager(accessToken, refreshToken, sandboxID)
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ func HandleControl(c *gin.Context) {
|
|||
MachineID: regMsg.MachineID,
|
||||
Version: regMsg.Version,
|
||||
Auth: authInfo,
|
||||
System: regMsg.System,
|
||||
Mode: "tunnel",
|
||||
YaoBase: regMsg.Server,
|
||||
Ports: regMsg.Ports,
|
||||
|
|
@ -159,13 +160,14 @@ func HandleData(c *gin.Context) {
|
|||
|
||||
// registerMessage is the JSON structure for Tai's register message.
|
||||
type registerMessage struct {
|
||||
Type string `json:"type"`
|
||||
TaiID string `json:"tai_id"`
|
||||
MachineID string `json:"machine_id"`
|
||||
Version string `json:"version"`
|
||||
Server string `json:"server"`
|
||||
Ports map[string]int `json:"ports"`
|
||||
Capabilities map[string]bool `json:"capabilities"`
|
||||
Type string `json:"type"`
|
||||
TaiID string `json:"tai_id"`
|
||||
MachineID string `json:"machine_id"`
|
||||
Version string `json:"version"`
|
||||
Server string `json:"server"`
|
||||
Ports map[string]int `json:"ports"`
|
||||
Capabilities map[string]bool `json:"capabilities"`
|
||||
System registry.SystemInfo `json:"system"`
|
||||
}
|
||||
|
||||
// controlMsg is a generic control channel message.
|
||||
|
|
|
|||
35
tai/yao.go
Normal file
35
tai/yao.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package tai
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
grpcclient "github.com/yaoapp/yao/grpc/client"
|
||||
"github.com/yaoapp/yao/grpc/pb"
|
||||
)
|
||||
|
||||
// YaoClient wraps grpc/client.Client for backward compatibility.
|
||||
// New code should use grpc/client.Client directly.
|
||||
type YaoClient = grpcclient.Client
|
||||
|
||||
// NewYaoClientFromEnv reads YAO_GRPC_ADDR and token env vars, dials the
|
||||
// gRPC server, and returns a connected YaoClient.
|
||||
func NewYaoClientFromEnv() (*YaoClient, error) {
|
||||
return grpcclient.NewFromEnv()
|
||||
}
|
||||
|
||||
// DialYao connects to a Yao gRPC server at addr with the given TokenManager.
|
||||
func DialYao(addr string, tm *TokenManager) (*YaoClient, error) {
|
||||
return grpcclient.Dial(addr, tm)
|
||||
}
|
||||
|
||||
// --- Convenience wrappers kept for sandbox/container code ---
|
||||
|
||||
// Run executes a Yao process via the given client.
|
||||
func Run(ctx context.Context, c *YaoClient, process string, args []byte, timeout int32) ([]byte, error) {
|
||||
return c.Run(ctx, process, args, timeout)
|
||||
}
|
||||
|
||||
// Shell executes a system command via the given client.
|
||||
func Shell(ctx context.Context, c *YaoClient, command string, args []string, env map[string]string, timeout int32) (*pb.ShellResponse, error) {
|
||||
return c.Shell(ctx, command, args, env, timeout)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue