From 2c1377dfb39b6eb466eb83559ac5f43c10d7de10 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 13 Jun 2026 10:08:24 +0100 Subject: [PATCH 1/5] =?UTF-8?q?chore(deps):=20bump=20dappco.re/go=20v0.10.?= =?UTF-8?q?3=20=E2=86=92=20v0.10.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advance external/go workspace submodule to v0.10.4 so dev (GOWORK on) and standalone (GOWORK=off) builds resolve the same core/go. Co-Authored-By: Virgil --- external/go | 2 +- go/go.mod | 2 +- go/go.sum | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/external/go b/external/go index f7a84db..7c95f96 160000 --- a/external/go +++ b/external/go @@ -1 +1 @@ -Subproject commit f7a84db6ce08722dc3d42ad72ed9094621fca992 +Subproject commit 7c95f964f84bd52c728c67c9cce49f1b9bf5e066 diff --git a/go/go.mod b/go/go.mod index 10362d2..062c288 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,7 +3,7 @@ module dappco.re/go/api go 1.26.2 require ( - dappco.re/go v0.10.3 + dappco.re/go v0.10.4 dappco.re/go/inference v0.9.0 dappco.re/go/io v0.9.0 dappco.re/go/log v0.9.0 diff --git a/go/go.sum b/go/go.sum index 53349ba..44e8d75 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,7 +1,5 @@ -dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= -dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= -dappco.re/go v0.10.3 h1:aViRNxdg2jG84P6RsiD+aSta+GcFJwGXMNQPjFPbJ9g= -dappco.re/go v0.10.3/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +dappco.re/go v0.10.4 h1:vir5AK8AkHbTxhPUT0et6Tc0P8i/i+gLInM0LRLt1EU= +dappco.re/go v0.10.4/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= dappco.re/go/inference v0.9.0 h1:6eD49KTjj4xrowWdltobEWZYLPY+zbiyDiq+Hv2nkmc= dappco.re/go/inference v0.9.0/go.mod h1:eu0je5UqOQyoG6eaJ1IqY5eORev+PfmsRXSNCanqBkk= dappco.re/go/io v0.9.0 h1:TyHUuUJdZ73CXQlBpqx47SNyFFzgwA5OPSKu4Twb2f0= From d1a6074c5643ad12a1fa79469195d363d0eaa25b Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 13 Jun 2026 17:54:33 +0000 Subject: [PATCH 2/5] feat(grpc): GoService/DenoService sidecar bridge + gateway wiring Wire the CoreGO side of the CoreGO <-> CoreDeno gRPC sidecar contract (code/core/go/api/RFC.grpc.md) into the gateway so it actually serves. - pkg/grpc: GoService (sandboxed I/O, KV state, exec, core:// scheme), DenoClient, and the GRPCServer (Unix socket / TCP loopback transport). - pkg/proto(+gen): core_sidecar.proto and generated bindings. - cmd/gateway: construct GoService against real subsystems (io.Local, a go-store KV database, the shared go-process Service) and serve it on a Unix domain socket (CORE_GATEWAY_GRPC_SOCKET, default under .core/run). kvStoreAdapter/procRunnerAdapter bridge go-store's error contract and go-process RunOptions to the GoService consumer interfaces. The process Service is now built once and shared by the HTTP provider and the gRPC bridge. Startup is additive: store/listen failures are logged and HTTP serving continues; the server stops gracefully on context cancel and via the cleanup stack. - go.mod/go.sum: add google.golang.org/grpc + dappco.re/go/store deps. Co-Authored-By: Virgil --- go/cmd/gateway/main.go | 205 ++- go/go.mod | 35 +- go/go.sum | 110 +- go/pkg/grpc/denoclient.go | 164 +++ go/pkg/grpc/denoclient_test.go | 150 +++ go/pkg/grpc/goservice.go | 344 +++++ go/pkg/grpc/server.go | 210 ++++ go/pkg/grpc/server_test.go | 426 +++++++ go/pkg/proto/core_sidecar.proto | 154 +++ go/pkg/proto/gen/core_sidecar.pb.go | 1447 ++++++++++++++++++++++ go/pkg/proto/gen/core_sidecar_grpc.pb.go | 629 ++++++++++ 11 files changed, 3841 insertions(+), 33 deletions(-) create mode 100644 go/pkg/grpc/denoclient.go create mode 100644 go/pkg/grpc/denoclient_test.go create mode 100644 go/pkg/grpc/goservice.go create mode 100644 go/pkg/grpc/server.go create mode 100644 go/pkg/grpc/server_test.go create mode 100644 go/pkg/proto/core_sidecar.proto create mode 100644 go/pkg/proto/gen/core_sidecar.pb.go create mode 100644 go/pkg/proto/gen/core_sidecar_grpc.pb.go diff --git a/go/cmd/gateway/main.go b/go/cmd/gateway/main.go index ae6f5bb..70a3926 100644 --- a/go/cmd/gateway/main.go +++ b/go/cmd/gateway/main.go @@ -12,12 +12,14 @@ import ( core "dappco.re/go" coreapi "dappco.re/go/api" + coregrpc "dappco.re/go/api/pkg/grpc" coreio "dappco.re/go/io" process "dappco.re/go/process" proxy "dappco.re/go/proxy" "dappco.re/go/scm/marketplace" scmapi "dappco.re/go/scm/pkg/api" "dappco.re/go/scm/repos" + store "dappco.re/go/store" "dappco.re/go/ws" "github.com/gin-gonic/gin" ) @@ -26,6 +28,19 @@ const ( defaultGatewayBind = "0.0.0.0:8080" envGatewayBind = "CORE_GATEWAY_BIND" envGatewayEnable = "CORE_GATEWAY_ENABLE" + + // envGatewayGRPCSocket overrides the Unix domain socket the gRPC + // sidecar bridge (GoService) listens on. When empty the gateway uses + // defaultGatewayGRPCSocket under the workspace .core directory. + envGatewayGRPCSocket = "CORE_GATEWAY_GRPC_SOCKET" + // defaultGatewayGRPCSocket is the sidecar socket path used when + // envGatewayGRPCSocket is unset. Deno dials this to reach Go. + defaultGatewayGRPCSocket = ".core/run/core-sidecar.sock" + // defaultSidecarStorePath is the SQLite KV database backing the + // GoService StoreGet/StoreSet rpcs when no override is supplied. + defaultSidecarStorePath = ".core/run/sidecar-store.db" + // envGatewaySidecarStore overrides defaultSidecarStorePath. + envGatewaySidecarStore = "CORE_GATEWAY_SIDECAR_STORE" ) type providerFactory func(*gatewayDeps) coreapi.RouteGroup @@ -43,6 +58,12 @@ type gatewayDeps struct { hub *ws.Hub logger *slog.Logger cleanup []func(context.Context) + + // procService is the single go-process Service shared by the HTTP + // process provider and the gRPC sidecar GoService. It is constructed + // once in run via ensureProcessService so both consumers exec through + // the same daemon rather than spinning up duplicate services. + procService *process.Service } type processRouteGroup struct { @@ -69,6 +90,172 @@ func (g processRouteGroup) RegisterRoutes(rg *gin.RouterGroup) { }) } +// ensureProcessService returns the gateway's shared go-process Service, +// constructing it on first use and registering its shutdown cleanup +// exactly once. Both the HTTP process provider and the gRPC sidecar +// GoService call this so a single daemon backs every exec. +// +// svc := ensureProcessService(deps) +func ensureProcessService(deps *gatewayDeps) *process.Service { + if deps.procService != nil { + return deps.procService + } + factory := process.NewService(process.Options{}) + result := factory(deps.core) + if !result.OK { + panic(result.Error()) + } + service, ok := result.Value.(*process.Service) + if !ok { + panic(core.Sprintf("process service factory returned %T", result.Value)) + } + deps.procService = service + deps.cleanup = append(deps.cleanup, func(ctx context.Context) { + if r := service.OnShutdown(ctx); !r.OK { + slog.Default().Warn("process service shutdown failed", "err", r.Error()) + } + }) + return service +} + +// kvStoreAdapter adapts a go-store *Store to the grpc.KVStore surface +// the GoService consumes. go-store returns plain errors; the bridge +// contract is core.Result, and an absent key must read back as an empty +// value on an OK Result (RFC.grpc.md StoreGet semantics), so a +// store.NotFoundError is folded into success here rather than surfaced. +// +// var kv coregrpc.KVStore = kvStoreAdapter{store: s} +type kvStoreAdapter struct { + store *store.Store +} + +// Get returns the value for (group, key). A missing key yields an empty +// value on an OK Result; any other backend error fails the Result. +func (a kvStoreAdapter) Get(group, key string) (string, core.Result) { + value, err := a.store.Get(group, key) + if err != nil { + if core.Is(err, store.NotFoundError) { + return "", core.Ok(nil) + } + return "", core.Fail(err) + } + return value, core.Ok(nil) +} + +// Set writes value under (group, key), translating a go-store error +// into a failed Result. +func (a kvStoreAdapter) Set(group, key, value string) core.Result { + if err := a.store.Set(group, key, value); err != nil { + return core.Fail(err) + } + return core.Ok(nil) +} + +// procRunnerAdapter adapts a go-process *Service to the grpc.ProcRunner +// surface. The bridge's RunOptions is a narrow wire-facing struct, so +// this maps it onto the concrete go-process RunOptions at the call site. +// +// var r coregrpc.ProcRunner = procRunnerAdapter{service: svc} +type procRunnerAdapter struct { + service *process.Service +} + +// RunWithOptions executes a command through go-process and returns the +// captured output on the Result. +func (a procRunnerAdapter) RunWithOptions(ctx context.Context, opts coregrpc.RunOptions) core.Result { + return a.service.RunWithOptions(ctx, process.RunOptions{ + Command: opts.Command, + Args: opts.Args, + Dir: opts.Dir, + Env: opts.Env, + }) +} + +// startSidecarBridge wires the gRPC sidecar GoService to real Core +// subsystems (go-io Local medium, a go-store KV database, the shared +// go-process Service) and serves it on a Unix domain socket. Deno dials +// this socket for sandboxed I/O, KV state, and process execution. +// +// The bridge is additive: any failure to open the store or bind the +// socket is logged and the gateway continues serving HTTP. The server +// is stopped gracefully both when Core's context is cancelled (signal / +// shutdown) and via the cleanup stack run on exit. +func startSidecarBridge(deps *gatewayDeps) { + logger := deps.logger + if logger == nil { + logger = slog.Default() + } + + socket := core.Trim(core.Getenv(envGatewayGRPCSocket)) + if socket == "" { + socket = defaultGatewayGRPCSocket + } + if r := core.MkdirAll(core.PathDir(socket), 0o755); !r.OK { + logger.Error("sidecar bridge socket dir create failed", "path", core.PathDir(socket), "err", r.Error()) + return + } + + goService := coregrpc.NewGoService( + coreio.Local, + openSidecarStore(logger), + procRunnerAdapter{service: ensureProcessService(deps)}, + ) + + srv, err := coregrpc.NewGRPCServer( + coregrpc.WithGRPCSocket(socket), + coregrpc.WithGRPCServices(goService), + ) + if err != nil { + logger.Error("sidecar bridge listen failed", "socket", socket, "err", err) + return + } + + // Stop gracefully on cleanup (exit path) and when Core's context is + // cancelled (signal / ServiceShutdown). srv.Stop is idempotent. + deps.cleanup = append(deps.cleanup, func(context.Context) { srv.Stop() }) + if deps.core != nil { + ctx := deps.core.Context() + deps.core.Go(func() { + <-ctx.Done() + srv.Stop() + }) + deps.core.Go(func() { + if serveErr := srv.Serve(); serveErr != nil { + logger.Error("sidecar bridge serve stopped with error", "err", serveErr) + } + }) + } else { + go func() { + if serveErr := srv.Serve(); serveErr != nil { + logger.Error("sidecar bridge serve stopped with error", "err", serveErr) + } + }() + } + + logger.Info("sidecar bridge listening", "socket", srv.Address()) +} + +// openSidecarStore opens the go-store SQLite KV database backing the +// GoService and returns it wrapped in the grpc.KVStore adapter. On +// failure it logs and returns nil, leaving StoreGet/StoreSet to report +// the subsystem as unavailable rather than aborting the gateway. +func openSidecarStore(logger *slog.Logger) coregrpc.KVStore { + path := core.Trim(core.Getenv(envGatewaySidecarStore)) + if path == "" { + path = defaultSidecarStorePath + } + if r := core.MkdirAll(core.PathDir(path), 0o755); !r.OK { + logger.Error("sidecar store dir create failed", "path", core.PathDir(path), "err", r.Error()) + return nil + } + s, err := store.New(path) + if err != nil { + logger.Error("sidecar store open failed", "path", path, "err", err) + return nil + } + return kvStoreAdapter{store: s} +} + func main() { core.Exit(run(core.Args()[1:], core.Stdout(), core.Stderr())) } @@ -118,6 +305,8 @@ func run(args []string, stdout io.Writer, stderr io.Writer) int { }) } + startSidecarBridge(deps) + stopSignals := forwardSignalsToCore(c, logger) defer stopSignals() @@ -170,21 +359,7 @@ func gatewayProviderSpecs() []providerSpec { BasePath: "/api/process", Description: "go-process daemon and process provider", New: func(deps *gatewayDeps) coreapi.RouteGroup { - factory := process.NewService(process.Options{}) - result := factory(deps.core) - if !result.OK { - panic(result.Error()) - } - service, ok := result.Value.(*process.Service) - if !ok { - panic(core.Sprintf("process service factory returned %T", result.Value)) - } - deps.cleanup = append(deps.cleanup, func(ctx context.Context) { - if r := service.OnShutdown(ctx); !r.OK { - slog.Default().Warn("process service shutdown failed", "err", r.Error()) - } - }) - return processRouteGroup{service: service} + return processRouteGroup{service: ensureProcessService(deps)} }, }, { diff --git a/go/go.mod b/go/go.mod index 062c288..2a84733 100644 --- a/go/go.mod +++ b/go/go.mod @@ -10,6 +10,7 @@ require ( dappco.re/go/process v0.10.0 dappco.re/go/proxy v0.0.0-20260428223938-a35a8ed3be11 dappco.re/go/scm v0.10.0 + dappco.re/go/store v0.10.0 dappco.re/go/ws v0.5.0 github.com/99designs/gqlgen v0.17.88 github.com/andybalholm/brotli v1.2.0 @@ -35,16 +36,20 @@ require ( github.com/swaggo/swag v1.16.6 github.com/vektah/gqlparser/v2 v2.5.32 go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 - go.opentelemetry.io/otel v1.42.0 - go.opentelemetry.io/otel/sdk v1.42.0 - go.opentelemetry.io/otel/trace v1.42.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 golang.org/x/text v0.36.0 + google.golang.org/grpc v1.81.1 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/apache/arrow-go/v18 v18.1.0 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.0 // indirect @@ -53,9 +58,10 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect @@ -74,35 +80,52 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/flatbuffers v25.1.24+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/influxdata/influxdb-client-go/v2 v2.14.0 // indirect + github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/marcboeker/go-duckdb v1.8.5 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/oapi-codegen/runtime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sosodev/duration v1.4.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.25.0 // indirect golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect golang.org/x/tools v0.43.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.47.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index 44e8d75..efe9660 100644 --- a/go/go.sum +++ b/go/go.sum @@ -12,6 +12,8 @@ dappco.re/go/proxy v0.0.0-20260428223938-a35a8ed3be11 h1:I8TPv5cvLbxvcrCz+m4f+3d dappco.re/go/proxy v0.0.0-20260428223938-a35a8ed3be11/go.mod h1:vQvKUYkR/NDP0zbExWgReKc5vf9w5+tbU/cBhAk2Flk= dappco.re/go/scm v0.10.0 h1:F+mwYbExNYxu6KLVfZCwfWUgMiP8bskCPSRgNYZl1I8= dappco.re/go/scm v0.10.0/go.mod h1:F6aMjXgK+/PBgmE3/C0ShmQPS3m55acD3WT6CoYkBGc= +dappco.re/go/store v0.10.0 h1:Ky9dTLgcTHrJxja6nUNUFhNYWQQhJEKh3NO/T9ShszY= +dappco.re/go/store v0.10.0/go.mod h1:GgRNVV+gvQ7tN8mv5hChdlMK1ZP/3Kc5OGhLYJwugis= dappco.re/go/ws v0.5.0 h1:PzFpOZdfyig4oLtFTgQ+mkp5LYtseJkmAug610zuymg= dappco.re/go/ws v0.5.0/go.mod h1:H7vsKo3RFWxv1F8B9du4rNZy1n+BCL8Fhr2oCMBv1jQ= forge.lthn.ai/Snider/Borg v0.3.1 h1:gfC1ZTpLoZai07oOWJiVeQ8+qJYK8A795tgVGJHbVL8= @@ -26,6 +28,7 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= @@ -34,6 +37,12 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y= +github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0= +github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= +github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= @@ -58,6 +67,7 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= @@ -92,6 +102,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/authz v1.0.6 h1:qAO4sSSzOPCwYRZI6YtubC+h2tZVwhwSJeyEZn2W+5k= @@ -122,8 +134,8 @@ github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC github.com/gin-contrib/timeout v1.1.0/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -172,11 +184,19 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o= +github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= @@ -189,8 +209,17 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= +github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= @@ -201,15 +230,27 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0= +github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= +github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -221,12 +262,15 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE= github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -253,6 +297,8 @@ github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6O github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= @@ -263,18 +309,18 @@ go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0. go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM= go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU= go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -289,6 +335,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= @@ -314,6 +362,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -330,6 +380,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -338,3 +396,31 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/go/pkg/grpc/denoclient.go b/go/pkg/grpc/denoclient.go new file mode 100644 index 0000000..dade729 --- /dev/null +++ b/go/pkg/grpc/denoclient.go @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package grpc + +import ( + "context" + + core "dappco.re/go" + sidecarpb "dappco.re/go/api/pkg/proto/gen" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +const denoClientScope = "grpc.DenoClient" + +// DenoClient is the Go-side client for a running Deno sidecar. It +// dials the Deno-hosted DenoService and exposes lifecycle, render, and +// eval calls. The address may be a TCP host:port or a Unix socket path +// prefixed with "unix:". +// +// client, _ := grpc.NewDenoClient("localhost:50052") +// defer client.Close() +// _ = client.OnConfigChange(ctx, "theme.accent", "#6366f1") +// html, _ := client.Render(ctx, "dashboard", `{"user":"snider"}`) +type DenoClient struct { + conn *grpc.ClientConn + client sidecarpb.DenoServiceClient +} + +// NewDenoClient dials the Deno sidecar at addr and returns a ready +// client. TLS is optional for localhost; this constructor uses an +// insecure transport suitable for loopback or Unix-socket sidecars. +// A "unix:" prefix selects Unix-socket transport. +// +// client, err := grpc.NewDenoClient("localhost:50052") +// defer client.Close() +func NewDenoClient(addr string) (*DenoClient, error) { + if core.Trim(addr) == "" { + return nil, core.E(denoClientScope, "empty address", nil) + } + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, core.E(denoClientScope, core.Concat("dial failed: ", addr), err) + } + return &DenoClient{ + conn: conn, + client: sidecarpb.NewDenoServiceClient(conn), + }, nil +} + +// NewDenoClientTLS dials the Deno sidecar over a TLS-secured transport +// using the supplied transport credentials. +// +// creds := credentials.NewTLS(tlsConfig) +// client, _ := grpc.NewDenoClientTLS("deno.internal:50052", creds) +func NewDenoClientTLS(addr string, creds credentials.TransportCredentials) (*DenoClient, error) { + if core.Trim(addr) == "" { + return nil, core.E(denoClientScope, "empty address", nil) + } + if creds == nil { + return nil, core.E(denoClientScope, "nil transport credentials", nil) + } + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(creds)) + if err != nil { + return nil, core.E(denoClientScope, core.Concat("dial failed: ", addr), err) + } + return &DenoClient{ + conn: conn, + client: sidecarpb.NewDenoServiceClient(conn), + }, nil +} + +// OnStart notifies the Deno runtime that a lifecycle start has +// occurred. reason is optional human context. +// +// _ = client.OnStart(ctx, "boot") +func (c *DenoClient) OnStart(ctx context.Context, reason string) error { + if c == nil || c.client == nil { + return core.E(denoClientScope, "client not initialised", nil) + } + _, err := c.client.OnStart(ctx, &sidecarpb.LifecycleEvent{Phase: "start", Reason: reason}) + if err != nil { + return core.E(denoClientScope, "OnStart failed", err) + } + return nil +} + +// OnStop notifies the Deno runtime that a lifecycle stop has occurred. +// +// _ = client.OnStop(ctx, "shutdown") +func (c *DenoClient) OnStop(ctx context.Context, reason string) error { + if c == nil || c.client == nil { + return core.E(denoClientScope, "client not initialised", nil) + } + _, err := c.client.OnStop(ctx, &sidecarpb.LifecycleEvent{Phase: "stop", Reason: reason}) + if err != nil { + return core.E(denoClientScope, "OnStop failed", err) + } + return nil +} + +// OnConfigChange notifies the Deno runtime of a settings change. +// +// _ = client.OnConfigChange(ctx, "theme.accent", "#6366f1") +func (c *DenoClient) OnConfigChange(ctx context.Context, key, value string) error { + if c == nil || c.client == nil { + return core.E(denoClientScope, "client not initialised", nil) + } + _, err := c.client.OnConfigChange(ctx, &sidecarpb.ConfigChangeEvent{Key: key, Value: value}) + if err != nil { + return core.E(denoClientScope, "OnConfigChange failed", err) + } + return nil +} + +// Render asks the Deno runtime to server-side render a component with +// the given JSON props, returning the rendered HTML. +// +// html, _ := client.Render(ctx, "dashboard", `{"user":"snider"}`) +func (c *DenoClient) Render(ctx context.Context, component, props string) (string, error) { + if c == nil || c.client == nil { + return "", core.E(denoClientScope, "client not initialised", nil) + } + resp, err := c.client.Render(ctx, &sidecarpb.RenderRequest{Component: component, Props: props}) + if err != nil { + return "", core.E(denoClientScope, "Render failed", err) + } + if resp.GetError() != "" { + return "", core.E(denoClientScope, resp.GetError(), nil) + } + return resp.GetHtml(), nil +} + +// Eval asks the Deno runtime to evaluate a TypeScript expression, +// returning the JSON-encoded result. +// +// resultJSON, _ := client.Eval(ctx, "1 + 1") +func (c *DenoClient) Eval(ctx context.Context, expression string) (string, error) { + if c == nil || c.client == nil { + return "", core.E(denoClientScope, "client not initialised", nil) + } + resp, err := c.client.Eval(ctx, &sidecarpb.EvalRequest{Expression: expression}) + if err != nil { + return "", core.E(denoClientScope, "Eval failed", err) + } + if resp.GetError() != "" { + return "", core.E(denoClientScope, resp.GetError(), nil) + } + return resp.GetResultJson(), nil +} + +// Close releases the client connection. Safe to call on a nil client. +// +// defer client.Close() +func (c *DenoClient) Close() error { + if c == nil || c.conn == nil { + return nil + } + if err := c.conn.Close(); err != nil { + return core.E(denoClientScope, "close failed", err) + } + return nil +} diff --git a/go/pkg/grpc/denoclient_test.go b/go/pkg/grpc/denoclient_test.go new file mode 100644 index 0000000..87760ba --- /dev/null +++ b/go/pkg/grpc/denoclient_test.go @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package grpc_test + +import ( + "context" + "net" + "testing" + + core "dappco.re/go" + apigrpc "dappco.re/go/api/pkg/grpc" + sidecarpb "dappco.re/go/api/pkg/proto/gen" + "google.golang.org/grpc" +) + +// fakeDenoServer stands in for the Deno-hosted DenoService. It records +// the last config change and can be told to fail Render/Eval to drive +// the Ugly path. +type fakeDenoServer struct { + sidecarpb.UnimplementedDenoServiceServer + lastKey string + lastValue string + failRender bool +} + +func (f *fakeDenoServer) OnStart(_ context.Context, _ *sidecarpb.LifecycleEvent) (*sidecarpb.Ack, error) { + return &sidecarpb.Ack{Ok: true}, nil +} + +func (f *fakeDenoServer) OnStop(_ context.Context, _ *sidecarpb.LifecycleEvent) (*sidecarpb.Ack, error) { + return &sidecarpb.Ack{Ok: true}, nil +} + +func (f *fakeDenoServer) OnConfigChange(_ context.Context, ev *sidecarpb.ConfigChangeEvent) (*sidecarpb.Ack, error) { + f.lastKey = ev.GetKey() + f.lastValue = ev.GetValue() + return &sidecarpb.Ack{Ok: true}, nil +} + +func (f *fakeDenoServer) Render(_ context.Context, req *sidecarpb.RenderRequest) (*sidecarpb.RenderResponse, error) { + if f.failRender { + return &sidecarpb.RenderResponse{Error: "render exploded"}, nil + } + return &sidecarpb.RenderResponse{Html: core.Concat("
", req.GetComponent(), "
")}, nil +} + +func (f *fakeDenoServer) Eval(_ context.Context, req *sidecarpb.EvalRequest) (*sidecarpb.EvalResponse, error) { + return &sidecarpb.EvalResponse{ResultJson: core.Concat(`"`, req.GetExpression(), `"`)}, nil +} + +// startFakeDeno serves a fakeDenoServer on a Unix socket and returns +// the dial address ("unix:") plus the backing fake. +func startFakeDeno(t *testing.T, fake *fakeDenoServer) string { + t.Helper() + socket := core.JoinPath(t.TempDir(), "deno.sock") + lis, err := net.Listen("unix", socket) + if err != nil { + t.Fatalf("listen: %v", err) + } + srv := grpc.NewServer() + sidecarpb.RegisterDenoServiceServer(srv, fake) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.GracefulStop) + return "unix:" + socket +} + +func TestDenoClient_Lifecycle_Good(t *testing.T) { + t.Parallel() + fake := &fakeDenoServer{} + addr := startFakeDeno(t, fake) + + client, err := apigrpc.NewDenoClient(addr) + if err != nil { + t.Fatalf("NewDenoClient: %v", err) + } + defer func() { _ = client.Close() }() + c := ctx(t) + + if err := client.OnStart(c, "boot"); err != nil { + t.Fatalf("OnStart: %v", err) + } + if err := client.OnConfigChange(c, "theme.accent", "#6366f1"); err != nil { + t.Fatalf("OnConfigChange: %v", err) + } + if fake.lastKey != "theme.accent" || fake.lastValue != "#6366f1" { + t.Fatalf("config not received: %q=%q", fake.lastKey, fake.lastValue) + } + + html, err := client.Render(c, "dashboard", `{"user":"snider"}`) + if err != nil { + t.Fatalf("Render: %v", err) + } + if html != "
dashboard
" { + t.Fatalf("html = %q", html) + } + + result, err := client.Eval(c, "1+1") + if err != nil { + t.Fatalf("Eval: %v", err) + } + if result != `"1+1"` { + t.Fatalf("eval = %q", result) + } + + if err := client.OnStop(c, "shutdown"); err != nil { + t.Fatalf("OnStop: %v", err) + } +} + +func TestDenoClient_NewDenoClient_Bad(t *testing.T) { + t.Parallel() + // An empty address must be rejected before any dial attempt. + if _, err := apigrpc.NewDenoClient(""); err == nil { + t.Fatal("expected error for empty address") + } + // A nil transport credential to the TLS constructor is rejected. + if _, err := apigrpc.NewDenoClientTLS("localhost:1", nil); err == nil { + t.Fatal("expected error for nil TLS credentials") + } +} + +func TestDenoClient_Render_Ugly(t *testing.T) { + t.Parallel() + // The Deno side returns an application error in the response body + // (not a transport error). The client must convert that populated + // error field into a Go error. + fake := &fakeDenoServer{failRender: true} + addr := startFakeDeno(t, fake) + + client, err := apigrpc.NewDenoClient(addr) + if err != nil { + t.Fatalf("NewDenoClient: %v", err) + } + defer func() { _ = client.Close() }() + + if _, err := client.Render(ctx(t), "dashboard", "{}"); err == nil { + t.Fatal("expected render error surfaced from response body") + } else if !core.Contains(err.Error(), "render exploded") { + t.Fatalf("error = %q", err.Error()) + } + + // Calls on a nil client must not panic. + var nilClient *apigrpc.DenoClient + if err := nilClient.OnStart(ctx(t), "x"); err == nil { + t.Fatal("expected error from nil client") + } + if err := nilClient.Close(); err != nil { + t.Fatalf("nil Close should be a no-op, got %v", err) + } +} diff --git a/go/pkg/grpc/goservice.go b/go/pkg/grpc/goservice.go new file mode 100644 index 0000000..5a227b7 --- /dev/null +++ b/go/pkg/grpc/goservice.go @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package grpc implements the CoreGO side of the CoreGO ↔ CoreDeno +// sidecar contract defined in code/core/go/api/RFC.grpc.md. +// +// Go hosts a gRPC server (GoService) that Deno calls for sandboxed +// I/O, KV state, process execution, and core:// scheme resolution. +// Go also acts as a client (DenoClient) to drive the Deno TypeScript +// runtime's lifecycle and rendering. +// +// The service wires to real Core subsystems through narrow consumer- +// side interfaces (IOMedium, KVStore, ProcRunner, SchemeResolver) so +// the api module never hard-imports go-store. The concrete types from +// go-io (io.Medium), go-store (*store.Store), and go-process +// (*process.Service) satisfy these interfaces structurally at the +// call site. This keeps the dependency arrow pointing the right way +// (AX principle 8: lib never imports consumer). +// +// svc := grpc.NewGoService(io.Local, kvStore, procRunner) +// srv, _ := grpc.NewGRPCServer( +// grpc.WithGRPCPort(50051), +// grpc.WithGRPCServices(svc), +// ) +// defer srv.Stop() +package grpc + +import ( + "context" + "io/fs" + + core "dappco.re/go" + sidecarpb "dappco.re/go/api/pkg/proto/gen" +) + +// IOMedium is the file-I/O surface the GoService consumes. It is the +// subset of go-io's Medium interface the sidecar needs, so api never +// imports go-io directly. io.Medium satisfies this structurally. +// +// var m IOMedium = io.Local +type IOMedium interface { + // Read returns the full content of a sandbox-relative path. + Read(path string) (string, error) + // WriteMode writes content at the given file mode. + WriteMode(path, content string, mode fs.FileMode) error + // List returns directory entries under a sandbox-relative path. + List(path string) ([]fs.DirEntry, error) +} + +// KVStore is the key/value surface the GoService consumes. It matches +// go-store's *Store (group, key) shape returning a core.Result, so api +// never imports go-store directly. *store.Store satisfies it. +// +// var s KVStore = storeInstance +type KVStore interface { + // Get returns the value for (group, key). The Result is failed + // when the backend errors; an absent key yields ("", ok-Result). + Get(group, key string) (string, core.Result) + // Set writes value under (group, key). + Set(group, key, value string) core.Result +} + +// ProcRunner is the process-execution surface the GoService consumes. +// It matches go-process's Service.RunWithOptions contract returning a +// core.Result whose Value is the captured output string on success. +// +// var r ProcRunner = processService +type ProcRunner interface { + // RunWithOptions executes a command and blocks until it exits. + // On success the Result.Value is the combined output (string). + RunWithOptions(ctx context.Context, opts RunOptions) core.Result +} + +// RunOptions describes a process to execute. It mirrors the fields the +// sidecar exposes over the wire; the gateway adapts it to the concrete +// go-process RunOptions. +type RunOptions struct { + // Command is the executable to run. + Command string + // Args are the command arguments. + Args []string + // Dir is the optional working directory. + Dir string + // Env carries KEY=VALUE environment overrides. + Env []string +} + +// SchemeResolver resolves a core:// URL to a value. It matches the +// SchemeRegistry.Resolve contract from RFC.core-scheme.md §4 so the +// GoService can answer ResolveScheme without importing the registry +// implementation directly. +// +// var r SchemeResolver = registry +type SchemeResolver interface { + // Resolve parses a core:// URL and returns the resolution value. + Resolve(url string) (any, error) +} + +// ModuleGuard authorises a single GoService rpc on behalf of the module +// that initiated it (the request's module_code). It returns a failed +// core.Result to deny the call; the rpc then reports that error in its +// response Error field instead of touching a subsystem. A nil guard +// allows every call, so module_code is recorded but not enforced. +// +// guard := func(rpc, moduleCode string) core.Result { +// if moduleCode == "" { +// return core.Fail(core.E("perm", "anonymous call denied", nil)) +// } +// return core.Ok(nil) +// } +type ModuleGuard func(rpc, moduleCode string) core.Result + +// GoService implements the sidecarpb.GoServiceServer rpcs against the +// injected Core subsystems. Any subsystem may be nil; the matching +// rpcs then report a Core "unavailable" error rather than panicking. +// +// Every GoService request carries a module_code naming the module that +// initiated the call. The optional ModuleGuard (see WithModuleScope) +// turns that field into a per-call permission check; without a guard +// the field is passed through for downstream auditing only. +// +// svc := grpc.NewGoService(io.Local, kvStore, procRunner) +// svc.WithScheme(registry) +type GoService struct { + sidecarpb.UnimplementedGoServiceServer + + medium IOMedium + store KVStore + runner ProcRunner + resolver SchemeResolver + guard ModuleGuard +} + +const goServiceScope = "grpc.GoService" + +// NewGoService constructs a GoService wired to the given subsystems. +// Pass nil for any subsystem the deployment does not expose. +// +// svc := grpc.NewGoService(io.Local, kvStore, procRunner) +func NewGoService(medium IOMedium, store KVStore, runner ProcRunner) *GoService { + return &GoService{ + medium: medium, + store: store, + runner: runner, + } +} + +// WithScheme attaches a core:// scheme resolver and returns the +// service for chaining. ResolveScheme reports unavailable until set. +// +// svc := grpc.NewGoService(io.Local, kv, proc).WithScheme(registry) +func (s *GoService) WithScheme(resolver SchemeResolver) *GoService { + s.resolver = resolver + return s +} + +// WithModuleScope attaches a ModuleGuard so each rpc is authorised +// against the request's module_code before reaching a subsystem, and +// returns the service for chaining. Without a guard, module_code is +// accepted and passed through but never blocks a call. +// +// svc := grpc.NewGoService(io.Local, kv, proc).WithModuleScope(guard) +func (s *GoService) WithModuleScope(guard ModuleGuard) *GoService { + s.guard = guard + return s +} + +// scope runs the ModuleGuard for rpc on behalf of moduleCode. A nil +// guard is an allow-all pass-through, so the returned Result is OK. +// +// if r := s.scope("ReadFile", req.GetModuleCode()); !r.OK { ... } +func (s *GoService) scope(rpc, moduleCode string) core.Result { + if s.guard == nil { + return core.Ok(nil) + } + return s.guard(rpc, moduleCode) +} + +// ReadFile reads a sandbox-relative path through the go-io Medium. +// +// resp, _ := svc.ReadFile(ctx, &sidecarpb.ReadFileRequest{Path: "config/app.yaml"}) +func (s *GoService) ReadFile(_ context.Context, req *sidecarpb.ReadFileRequest) (*sidecarpb.ReadFileResponse, error) { + if req == nil { + return &sidecarpb.ReadFileResponse{Error: "nil request"}, nil + } + if r := s.scope("ReadFile", req.GetModuleCode()); !r.OK { + return &sidecarpb.ReadFileResponse{Error: r.Error()}, nil + } + if s.medium == nil { + return &sidecarpb.ReadFileResponse{Error: unavailable("io")}, nil + } + content, err := s.medium.Read(req.GetPath()) + if err != nil { + return &sidecarpb.ReadFileResponse{Error: err.Error()}, nil + } + return &sidecarpb.ReadFileResponse{Data: []byte(content)}, nil +} + +// WriteFile writes content to a sandbox-relative path. A zero mode +// selects 0644, the Medium default. +// +// resp, _ := svc.WriteFile(ctx, &sidecarpb.WriteFileRequest{Path: "a.txt", Data: []byte("hi")}) +func (s *GoService) WriteFile(_ context.Context, req *sidecarpb.WriteFileRequest) (*sidecarpb.WriteFileResponse, error) { + if req == nil { + return &sidecarpb.WriteFileResponse{Error: "nil request"}, nil + } + if r := s.scope("WriteFile", req.GetModuleCode()); !r.OK { + return &sidecarpb.WriteFileResponse{Error: r.Error()}, nil + } + if s.medium == nil { + return &sidecarpb.WriteFileResponse{Error: unavailable("io")}, nil + } + mode := fs.FileMode(req.GetMode()) + if mode == 0 { + mode = 0644 + } + if err := s.medium.WriteMode(req.GetPath(), string(req.GetData()), mode); err != nil { + return &sidecarpb.WriteFileResponse{Error: err.Error()}, nil + } + return &sidecarpb.WriteFileResponse{Ok: true}, nil +} + +// ListFiles lists entries under a sandbox-relative directory. +// +// resp, _ := svc.ListFiles(ctx, &sidecarpb.ListFilesRequest{Path: "config"}) +func (s *GoService) ListFiles(_ context.Context, req *sidecarpb.ListFilesRequest) (*sidecarpb.ListFilesResponse, error) { + if req == nil { + return &sidecarpb.ListFilesResponse{Error: "nil request"}, nil + } + if r := s.scope("ListFiles", req.GetModuleCode()); !r.OK { + return &sidecarpb.ListFilesResponse{Error: r.Error()}, nil + } + if s.medium == nil { + return &sidecarpb.ListFilesResponse{Error: unavailable("io")}, nil + } + dirEntries, err := s.medium.List(req.GetPath()) + if err != nil { + return &sidecarpb.ListFilesResponse{Error: err.Error()}, nil + } + entries := make([]*sidecarpb.FileEntry, 0, len(dirEntries)) + for _, entry := range dirEntries { + entries = append(entries, &sidecarpb.FileEntry{ + Name: entry.Name(), + IsDir: entry.IsDir(), + }) + } + return &sidecarpb.ListFilesResponse{Entries: entries}, nil +} + +// StoreGet reads a value by (group, key) through go-store. +// +// resp, _ := svc.StoreGet(ctx, &sidecarpb.StoreGetRequest{Group: "workspace", Key: "task"}) +func (s *GoService) StoreGet(_ context.Context, req *sidecarpb.StoreGetRequest) (*sidecarpb.StoreGetResponse, error) { + if req == nil { + return &sidecarpb.StoreGetResponse{Error: "nil request"}, nil + } + if r := s.scope("StoreGet", req.GetModuleCode()); !r.OK { + return &sidecarpb.StoreGetResponse{Error: r.Error()}, nil + } + if s.store == nil { + return &sidecarpb.StoreGetResponse{Error: unavailable("store")}, nil + } + value, result := s.store.Get(req.GetGroup(), req.GetKey()) + if !result.OK { + return &sidecarpb.StoreGetResponse{Error: result.Error()}, nil + } + return &sidecarpb.StoreGetResponse{Value: value, Found: value != ""}, nil +} + +// StoreSet writes value under (group, key) through go-store. +// +// resp, _ := svc.StoreSet(ctx, &sidecarpb.StoreSetRequest{Group: "workspace", Key: "task", Value: "x"}) +func (s *GoService) StoreSet(_ context.Context, req *sidecarpb.StoreSetRequest) (*sidecarpb.StoreSetResponse, error) { + if req == nil { + return &sidecarpb.StoreSetResponse{Error: "nil request"}, nil + } + if r := s.scope("StoreSet", req.GetModuleCode()); !r.OK { + return &sidecarpb.StoreSetResponse{Error: r.Error()}, nil + } + if s.store == nil { + return &sidecarpb.StoreSetResponse{Error: unavailable("store")}, nil + } + result := s.store.Set(req.GetGroup(), req.GetKey(), req.GetValue()) + if !result.OK { + return &sidecarpb.StoreSetResponse{Error: result.Error()}, nil + } + return &sidecarpb.StoreSetResponse{Ok: true}, nil +} + +// Exec runs a command through go-process and returns its output. +// +// resp, _ := svc.Exec(ctx, &sidecarpb.ExecRequest{Cmd: "echo", Args: []string{"hi"}}) +func (s *GoService) Exec(ctx context.Context, req *sidecarpb.ExecRequest) (*sidecarpb.ExecResponse, error) { + if req == nil { + return &sidecarpb.ExecResponse{Error: "nil request"}, nil + } + if r := s.scope("Exec", req.GetModuleCode()); !r.OK { + return &sidecarpb.ExecResponse{ExitCode: 1, Error: r.Error()}, nil + } + if s.runner == nil { + return &sidecarpb.ExecResponse{Error: unavailable("process")}, nil + } + result := s.runner.RunWithOptions(ctx, RunOptions{ + Command: req.GetCmd(), + Args: req.GetArgs(), + Dir: req.GetDir(), + Env: req.GetEnv(), + }) + if !result.OK { + // A non-zero exit still carries output on the failed Result's + // scope; surface the error message and a non-zero code. + return &sidecarpb.ExecResponse{ExitCode: 1, Error: result.Error()}, nil + } + output, _ := result.Value.(string) + return &sidecarpb.ExecResponse{Output: []byte(output)}, nil +} + +// ResolveScheme resolves a core:// URL through the attached registry, +// JSON-encoding the result for the wire. +// +// resp, _ := svc.ResolveScheme(ctx, &sidecarpb.SchemeRequest{Uri: "core://settings/theme.accent"}) +func (s *GoService) ResolveScheme(_ context.Context, req *sidecarpb.SchemeRequest) (*sidecarpb.SchemeResponse, error) { + if req == nil { + return &sidecarpb.SchemeResponse{Error: "nil request"}, nil + } + if r := s.scope("ResolveScheme", req.GetModuleCode()); !r.OK { + return &sidecarpb.SchemeResponse{Error: r.Error()}, nil + } + if s.resolver == nil { + return &sidecarpb.SchemeResponse{Error: unavailable("scheme")}, nil + } + value, err := s.resolver.Resolve(req.GetUri()) + if err != nil { + return &sidecarpb.SchemeResponse{Error: err.Error()}, nil + } + return &sidecarpb.SchemeResponse{ResultJson: core.JSONMarshalString(value)}, nil +} + +// unavailable formats the Core error message for a missing subsystem. +func unavailable(subsystem string) string { + return core.E(goServiceScope, core.Concat(subsystem, " subsystem not configured"), nil).Error() +} + +// Static assertion: GoService implements the generated server. +var _ sidecarpb.GoServiceServer = (*GoService)(nil) diff --git a/go/pkg/grpc/server.go b/go/pkg/grpc/server.go new file mode 100644 index 0000000..26828fc --- /dev/null +++ b/go/pkg/grpc/server.go @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package grpc + +import ( + "crypto/tls" + "net" + "sync" + + core "dappco.re/go" + sidecarpb "dappco.re/go/api/pkg/proto/gen" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +const serverScope = "grpc.Server" + +// Default transport: a Unix domain socket is preferred in production +// (faster than TCP loopback per RFC.grpc.md §5). When no socket path +// is configured the server falls back to TCP on loopback. +const ( + defaultGRPCHost = "127.0.0.1" + defaultGRPCPort = 50051 +) + +// GRPCServer hosts the GoService for Deno sidecar communication. It +// owns the underlying *grpc.Server and the listener (Unix socket or +// TCP loopback) it serves on. +// +// srv, _ := grpc.NewGRPCServer( +// grpc.WithGRPCPort(50051), +// grpc.WithGRPCServices(grpc.NewGoService(io.Local, kv, proc)), +// ) +// go srv.Serve() +// defer srv.Stop() +type GRPCServer struct { + host string + port int + socketPath string + tlsConfig *tls.Config + goServices []*GoService + + server *grpc.Server + listener net.Listener + mu sync.Mutex +} + +// GRPCOption configures a GRPCServer during construction. +// +// srv, _ := grpc.NewGRPCServer(grpc.WithGRPCPort(50051)) +type GRPCOption func(*GRPCServer) + +// WithGRPCPort sets the TCP loopback port used when no Unix socket is +// configured. +// +// grpc.NewGRPCServer(grpc.WithGRPCPort(50051)) +func WithGRPCPort(port int) GRPCOption { + return func(s *GRPCServer) { + s.port = port + } +} + +// WithGRPCHost overrides the loopback host (default 127.0.0.1). The +// address must remain loopback unless TLS is configured. +// +// grpc.NewGRPCServer(grpc.WithGRPCHost("127.0.0.1")) +func WithGRPCHost(host string) GRPCOption { + return func(s *GRPCServer) { + s.host = host + } +} + +// WithGRPCSocket serves over a Unix domain socket at the given path +// instead of TCP. This is the preferred production transport. +// +// grpc.NewGRPCServer(grpc.WithGRPCSocket("/run/core/sidecar.sock")) +func WithGRPCSocket(path string) GRPCOption { + return func(s *GRPCServer) { + s.socketPath = path + } +} + +// WithGRPCServices registers one or more GoService implementations. +// +// grpc.NewGRPCServer(grpc.WithGRPCServices(grpc.NewGoService(io.Local, kv, proc))) +func WithGRPCServices(services ...*GoService) GRPCOption { + return func(s *GRPCServer) { + for _, svc := range services { + if svc != nil { + s.goServices = append(s.goServices, svc) + } + } + } +} + +// WithGRPCTLS enables TLS for the listener. TLS is optional for +// localhost sidecar communication (RFC.grpc.md §5). +// +// grpc.NewGRPCServer(grpc.WithGRPCTLS(cfg)) +func WithGRPCTLS(cfg *tls.Config) GRPCOption { + return func(s *GRPCServer) { + s.tlsConfig = cfg + } +} + +// NewGRPCServer builds and binds a GRPCServer. It opens the listener +// (Unix socket if WithGRPCSocket was set, else TCP loopback) and +// registers every configured GoService on the underlying grpc.Server. +// Call Serve to begin accepting; Stop to shut down gracefully. +// +// srv, err := grpc.NewGRPCServer( +// grpc.WithGRPCPort(50051), +// grpc.WithGRPCServices(grpc.NewGoService(io.Local, kv, proc)), +// ) +// defer srv.Stop() +func NewGRPCServer(opts ...GRPCOption) (*GRPCServer, error) { + s := &GRPCServer{ + host: defaultGRPCHost, + port: defaultGRPCPort, + } + for _, opt := range opts { + if opt != nil { + opt(s) + } + } + + listener, err := s.listen() + if err != nil { + return nil, err + } + + var serverOpts []grpc.ServerOption + if s.tlsConfig != nil { + serverOpts = append(serverOpts, grpc.Creds(credentials.NewTLS(s.tlsConfig))) + } + + s.server = grpc.NewServer(serverOpts...) + for _, svc := range s.goServices { + sidecarpb.RegisterGoServiceServer(s.server, svc) + } + s.listener = listener + return s, nil +} + +// listen opens the configured transport. A Unix socket path takes +// precedence; otherwise TCP loopback on host:port is used. +func (s *GRPCServer) listen() (net.Listener, error) { + if core.Trim(s.socketPath) != "" { + // A stale socket file blocks bind; remove it best-effort. + _ = core.Remove(s.socketPath) + listener, err := net.Listen("unix", s.socketPath) + if err != nil { + return nil, core.E(serverScope, core.Concat("unix listen failed: ", s.socketPath), err) + } + return listener, nil + } + addr := core.Sprintf("%s:%d", s.host, s.port) + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, core.E(serverScope, core.Concat("tcp listen failed: ", addr), err) + } + return listener, nil +} + +// Address returns the address the server is bound to: the socket path +// for Unix transport, or the host:port for TCP. +// +// addr := srv.Address() // "127.0.0.1:50051" or "/run/core/sidecar.sock" +func (s *GRPCServer) Address() string { + if core.Trim(s.socketPath) != "" { + return s.socketPath + } + if s.listener != nil { + return s.listener.Addr().String() + } + return core.Sprintf("%s:%d", s.host, s.port) +} + +// Serve begins accepting connections and blocks until Stop is called +// or the listener errors. Run it in its own goroutine. +// +// go srv.Serve() +func (s *GRPCServer) Serve() error { + s.mu.Lock() + server := s.server + listener := s.listener + s.mu.Unlock() + if server == nil || listener == nil { + return core.E(serverScope, "server not initialised", nil) + } + if err := server.Serve(listener); err != nil && err != grpc.ErrServerStopped { + return core.E(serverScope, "serve failed", err) + } + return nil +} + +// Stop gracefully stops the server and releases the listener. A nil or +// already-stopped server is a no-op, so Stop is safe in a defer. +// +// defer srv.Stop() +func (s *GRPCServer) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + if s.server != nil { + s.server.GracefulStop() + } + if core.Trim(s.socketPath) != "" { + _ = core.Remove(s.socketPath) + } +} diff --git a/go/pkg/grpc/server_test.go b/go/pkg/grpc/server_test.go new file mode 100644 index 0000000..3686bfb --- /dev/null +++ b/go/pkg/grpc/server_test.go @@ -0,0 +1,426 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package grpc_test + +import ( + "context" + "io/fs" + "testing" + "time" + + core "dappco.re/go" + apigrpc "dappco.re/go/api/pkg/grpc" + sidecarpb "dappco.re/go/api/pkg/proto/gen" + coreio "dappco.re/go/io" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// --- Fakes --------------------------------------------------------------- + +// fakeStore is an in-memory KVStore for table tests. A non-empty +// failGroup makes every call to that group return a failed Result, +// exercising the Ugly path. +type fakeStore struct { + data map[string]string + failGroup string +} + +func newFakeStore() *fakeStore { return &fakeStore{data: map[string]string{}} } + +func (s *fakeStore) key(group, key string) string { return core.Concat(group, "\x00", key) } + +func (s *fakeStore) Get(group, key string) (string, core.Result) { + if group == s.failGroup { + return "", core.Fail(core.E("fakeStore.Get", "backend down", nil)) + } + return s.data[s.key(group, key)], core.Ok(nil) +} + +func (s *fakeStore) Set(group, key, value string) core.Result { + if group == s.failGroup { + return core.Fail(core.E("fakeStore.Set", "backend down", nil)) + } + s.data[s.key(group, key)] = value + return core.Ok(nil) +} + +// fakeRunner is a ProcRunner that echoes a canned output or fails when +// the command equals "boom". +type fakeRunner struct{} + +func (fakeRunner) RunWithOptions(_ context.Context, opts apigrpc.RunOptions) core.Result { + if opts.Command == "boom" { + return core.Fail(core.E("fakeRunner", "exit 1", nil)) + } + return core.Ok(core.Concat("ran:", opts.Command)) +} + +// fakeResolver resolves any core:// URL to a fixed map, or errors when +// the URL contains "missing". +type fakeResolver struct{} + +func (fakeResolver) Resolve(url string) (any, error) { + if core.Contains(url, "missing") { + return nil, core.E("fakeResolver", "no such scheme segment", nil) + } + return map[string]string{"url": url, "value": "#6366f1"}, nil +} + +// dialServer brings up a GRPCServer on a Unix socket in t.TempDir and +// returns a connected GoServiceClient. Cleanup stops both ends. +func dialServer(t *testing.T, svc *apigrpc.GoService) sidecarpb.GoServiceClient { + t.Helper() + socket := core.JoinPath(t.TempDir(), "sidecar.sock") + srv, err := apigrpc.NewGRPCServer( + apigrpc.WithGRPCSocket(socket), + apigrpc.WithGRPCServices(svc), + ) + if err != nil { + t.Fatalf("NewGRPCServer: %v", err) + } + go func() { _ = srv.Serve() }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient("unix:"+socket, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return sidecarpb.NewGoServiceClient(conn) +} + +func ctx(t *testing.T) context.Context { + t.Helper() + c, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + return c +} + +// --- NewGRPCServer ------------------------------------------------------- + +func TestServer_NewGRPCServer_Good(t *testing.T) { + t.Parallel() + tests := []struct { + name string + opts []apigrpc.GRPCOption + want string // expected substring of Address() + }{ + { + name: "tcp loopback default port resolves", + opts: []apigrpc.GRPCOption{apigrpc.WithGRPCPort(0)}, + want: "127.0.0.1:", + }, + { + name: "unix socket transport", + opts: []apigrpc.GRPCOption{apigrpc.WithGRPCSocket(core.JoinPath(t.TempDir(), "s.sock"))}, + want: ".sock", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv, err := apigrpc.NewGRPCServer(tc.opts...) + if err != nil { + t.Fatalf("NewGRPCServer: %v", err) + } + defer srv.Stop() + if !core.Contains(srv.Address(), tc.want) { + t.Fatalf("Address() = %q, want substring %q", srv.Address(), tc.want) + } + }) + } +} + +func TestServer_NewGRPCServer_Bad(t *testing.T) { + t.Parallel() + // Binding a Unix socket inside a non-existent directory fails at + // listen() and must surface a Core error, not a nil server. + _, err := apigrpc.NewGRPCServer( + apigrpc.WithGRPCSocket("/this/path/does/not/exist/sidecar.sock"), + ) + if err == nil { + t.Fatal("expected listen error for non-existent socket directory") + } + if !core.Contains(err.Error(), "unix listen failed") { + t.Fatalf("error = %q, want unix listen failure", err.Error()) + } +} + +func TestServer_NewGRPCServer_Ugly(t *testing.T) { + t.Parallel() + // Two servers asked to bind the SAME Unix socket: the first wins, + // the second must fail to listen rather than silently share. + socket := core.JoinPath(t.TempDir(), "contended.sock") + first, err := apigrpc.NewGRPCServer(apigrpc.WithGRPCSocket(socket)) + if err != nil { + t.Fatalf("first NewGRPCServer: %v", err) + } + go func() { _ = first.Serve() }() + defer first.Stop() + + // NewGRPCServer best-effort removes a stale socket file, but a live + // listener on it cannot be rebound on the same path while held. + second, secondErr := apigrpc.NewGRPCServer(apigrpc.WithGRPCSocket(socket)) + if secondErr != nil { + return // expected: contended bind rejected + } + // On platforms that permit the rebind after unlink, ensure the + // second server at least produced a usable distinct listener and + // clean up so the test does not leak. + second.Stop() +} + +// --- GoService rpcs end-to-end ------------------------------------------ + +func TestServer_GoServiceRPCs_Good(t *testing.T) { + t.Parallel() + mem := coreio.NewMemoryMedium() + if err := mem.WriteMode("config/app.yaml", "port: 8080", 0644); err != nil { + t.Fatalf("seed: %v", err) + } + store := newFakeStore() + svc := apigrpc.NewGoService(mem, store, fakeRunner{}).WithScheme(fakeResolver{}) + client := dialServer(t, svc) + c := ctx(t) + + t.Run("ReadFile", func(t *testing.T) { + resp, err := client.ReadFile(c, &sidecarpb.ReadFileRequest{Path: "config/app.yaml"}) + if err != nil { + t.Fatalf("rpc: %v", err) + } + if resp.GetError() != "" { + t.Fatalf("app error: %s", resp.GetError()) + } + if string(resp.GetData()) != "port: 8080" { + t.Fatalf("data = %q", string(resp.GetData())) + } + }) + + t.Run("WriteFile then ListFiles", func(t *testing.T) { + if _, err := client.WriteFile(c, &sidecarpb.WriteFileRequest{Path: "config/new.txt", Data: []byte("hi")}); err != nil { + t.Fatalf("write rpc: %v", err) + } + resp, err := client.ListFiles(c, &sidecarpb.ListFilesRequest{Path: "config"}) + if err != nil { + t.Fatalf("list rpc: %v", err) + } + var names []string + for _, e := range resp.GetEntries() { + names = append(names, e.GetName()) + } + if len(names) != 2 { + t.Fatalf("entries = %v, want 2 files", names) + } + }) + + t.Run("StoreSet then StoreGet", func(t *testing.T) { + if _, err := client.StoreSet(c, &sidecarpb.StoreSetRequest{Group: "workspace", Key: "task", Value: "ship"}); err != nil { + t.Fatalf("set rpc: %v", err) + } + resp, err := client.StoreGet(c, &sidecarpb.StoreGetRequest{Group: "workspace", Key: "task"}) + if err != nil { + t.Fatalf("get rpc: %v", err) + } + if resp.GetValue() != "ship" || !resp.GetFound() { + t.Fatalf("get = %+v", resp) + } + }) + + t.Run("Exec", func(t *testing.T) { + resp, err := client.Exec(c, &sidecarpb.ExecRequest{Cmd: "echo", Args: []string{"hi"}}) + if err != nil { + t.Fatalf("exec rpc: %v", err) + } + if string(resp.GetOutput()) != "ran:echo" { + t.Fatalf("output = %q", string(resp.GetOutput())) + } + }) + + t.Run("ResolveScheme", func(t *testing.T) { + resp, err := client.ResolveScheme(c, &sidecarpb.SchemeRequest{Uri: "core://settings/theme.accent"}) + if err != nil { + t.Fatalf("scheme rpc: %v", err) + } + if !core.Contains(resp.GetResultJson(), "#6366f1") { + t.Fatalf("result = %q", resp.GetResultJson()) + } + }) +} + +func TestServer_GoServiceRPCs_Bad(t *testing.T) { + t.Parallel() + // A GoService with no subsystems wired must report Core + // "unavailable" errors in the response, not crash the stream. + svc := apigrpc.NewGoService(nil, nil, nil) + client := dialServer(t, svc) + c := ctx(t) + + readResp, err := client.ReadFile(c, &sidecarpb.ReadFileRequest{Path: "x"}) + if err != nil { + t.Fatalf("transport err: %v", err) + } + if !core.Contains(readResp.GetError(), "io subsystem not configured") { + t.Fatalf("ReadFile error = %q", readResp.GetError()) + } + + schemeResp, err := client.ResolveScheme(c, &sidecarpb.SchemeRequest{Uri: "core://x"}) + if err != nil { + t.Fatalf("transport err: %v", err) + } + if !core.Contains(schemeResp.GetError(), "scheme subsystem not configured") { + t.Fatalf("ResolveScheme error = %q", schemeResp.GetError()) + } +} + +func TestServer_GoServiceRPCs_Ugly(t *testing.T) { + t.Parallel() + // Subsystems present but each forced into its failure mode. The + // server must translate every failure into a populated error field + // while still returning a non-nil response and nil transport error. + mem := coreio.NewMemoryMedium() // empty: reads miss + store := newFakeStore() + store.failGroup = "broken" + svc := apigrpc.NewGoService(mem, store, fakeRunner{}).WithScheme(fakeResolver{}) + client := dialServer(t, svc) + c := ctx(t) + + cases := []struct { + name string + call func() string // returns the app-level error string + }{ + {"read missing path", func() string { + r, _ := client.ReadFile(c, &sidecarpb.ReadFileRequest{Path: "ghost"}) + return r.GetError() + }}, + {"store backend failure", func() string { + r, _ := client.StoreGet(c, &sidecarpb.StoreGetRequest{Group: "broken", Key: "k"}) + return r.GetError() + }}, + {"exec non-zero exit", func() string { + r, _ := client.Exec(c, &sidecarpb.ExecRequest{Cmd: "boom"}) + return r.GetError() + }}, + {"scheme resolution failure", func() string { + r, _ := client.ResolveScheme(c, &sidecarpb.SchemeRequest{Uri: "core://missing/x"}) + return r.GetError() + }}, + {"nil request guarded", func() string { + r, _ := client.WriteFile(c, &sidecarpb.WriteFileRequest{Path: "/dev/full/forbidden", Data: []byte("x"), Mode: uint32(fs.FileMode(0644))}) + return r.GetError() // MemoryMedium permits this; just assert no panic/transport error + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Each call must complete without a transport panic; the + // error string is allowed to be empty only for the last + // (write succeeds in memory). + _ = tc.call() + }) + } + + // Explicitly assert the three genuine failures carry messages. + if r, _ := client.ReadFile(c, &sidecarpb.ReadFileRequest{Path: "ghost"}); r.GetError() == "" { + t.Fatal("expected read-miss error") + } + if r, _ := client.StoreGet(c, &sidecarpb.StoreGetRequest{Group: "broken", Key: "k"}); !core.Contains(r.GetError(), "backend down") { + t.Fatalf("expected store failure, got %q", r.GetError()) + } + if r, _ := client.Exec(c, &sidecarpb.ExecRequest{Cmd: "boom"}); r.GetExitCode() == 0 { + t.Fatalf("expected non-zero exit, got %+v", r) + } +} + +// --- WithModuleScope (module_code permission scoping) ------------------- + +func TestServer_ModuleScope_Good(t *testing.T) { + t.Parallel() + // A guard that permits every module is equivalent to no guard: the + // call reaches the subsystem and the module_code rides along. + mem := coreio.NewMemoryMedium() + if err := mem.WriteMode("a.txt", "hi", 0644); err != nil { + t.Fatalf("seed: %v", err) + } + var sawRPC, sawModule string + guard := func(rpc, moduleCode string) core.Result { + sawRPC, sawModule = rpc, moduleCode + return core.Ok(nil) + } + svc := apigrpc.NewGoService(mem, nil, nil).WithModuleScope(guard) + client := dialServer(t, svc) + c := ctx(t) + + resp, err := client.ReadFile(c, &sidecarpb.ReadFileRequest{Path: "a.txt", ModuleCode: "ofm.agency"}) + if err != nil { + t.Fatalf("rpc: %v", err) + } + if resp.GetError() != "" || string(resp.GetData()) != "hi" { + t.Fatalf("resp = %+v", resp) + } + if sawRPC != "ReadFile" || sawModule != "ofm.agency" { + t.Fatalf("guard saw rpc=%q module=%q, want ReadFile/ofm.agency", sawRPC, sawModule) + } +} + +func TestServer_ModuleScope_Bad(t *testing.T) { + t.Parallel() + // A guard that denies a named module must short-circuit the rpc with + // the guard's error and never touch the subsystem. + store := newFakeStore() + store.data[store.key("workspace", "task")] = "ship" + guard := func(_, moduleCode string) core.Result { + if moduleCode == "untrusted" { + return core.Fail(core.E("perm", "module untrusted denied", nil)) + } + return core.Ok(nil) + } + svc := apigrpc.NewGoService(nil, store, nil).WithModuleScope(guard) + client := dialServer(t, svc) + c := ctx(t) + + denied, err := client.StoreGet(c, &sidecarpb.StoreGetRequest{Group: "workspace", Key: "task", ModuleCode: "untrusted"}) + if err != nil { + t.Fatalf("transport err: %v", err) + } + if !core.Contains(denied.GetError(), "module untrusted denied") { + t.Fatalf("expected denial, got %+v", denied) + } + if denied.GetValue() != "" || denied.GetFound() { + t.Fatalf("denied call leaked store data: %+v", denied) + } + + // A permitted module on the same service still succeeds. + allowed, err := client.StoreGet(c, &sidecarpb.StoreGetRequest{Group: "workspace", Key: "task", ModuleCode: "ofm.agency"}) + if err != nil { + t.Fatalf("transport err: %v", err) + } + if allowed.GetValue() != "ship" || !allowed.GetFound() { + t.Fatalf("permitted call = %+v", allowed) + } +} + +func TestServer_ModuleScope_Ugly(t *testing.T) { + t.Parallel() + // Empty module_code under a guard that rejects anonymous callers: the + // denial must land before the (nil) subsystem's unavailable error, so + // the error is the guard's, not "process subsystem not configured". + guard := func(_, moduleCode string) core.Result { + if core.Trim(moduleCode) == "" { + return core.Fail(core.E("perm", "anonymous module denied", nil)) + } + return core.Ok(nil) + } + svc := apigrpc.NewGoService(nil, nil, nil).WithModuleScope(guard) + client := dialServer(t, svc) + c := ctx(t) + + resp, err := client.Exec(c, &sidecarpb.ExecRequest{Cmd: "echo"}) // no ModuleCode + if err != nil { + t.Fatalf("transport err: %v", err) + } + if !core.Contains(resp.GetError(), "anonymous module denied") { + t.Fatalf("expected anonymous denial, got %+v", resp) + } + if resp.GetExitCode() == 0 { + t.Fatalf("denied exec must report non-zero exit, got %+v", resp) + } +} diff --git a/go/pkg/proto/core_sidecar.proto b/go/pkg/proto/core_sidecar.proto new file mode 100644 index 0000000..5a76ade --- /dev/null +++ b/go/pkg/proto/core_sidecar.proto @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: EUPL-1.2 +// +// core_sidecar.proto +// +// CANONICAL contract between CoreGO and CoreDeno (CoreTS). Single source of +// truth: the Go stubs (api/go/pkg/proto/gen/) and the Deno proto-loader BOTH +// derive from this exact file. Spec of record: code/core/go/api/RFC.grpc.md §2. +// +// Go hosts the GoService server; Deno connects as client. +// Deno hosts the DenoService server; Go connects as client. +// +// Message types match the wired subsystems: go-io Medium (file I/O), +// go-store Store (KV, group+key), go-process Service (exec), the core:// +// scheme registry, and the CoreDeno runtime (DenoService lifecycle + render). +// +// Every GoService request carries module_code — the module that initiated the +// call — so Go applies per-module permission scoping. + +syntax = "proto3"; + +package core.sidecar; + +option go_package = "dappco.re/go/api/pkg/proto/gen;sidecarpb"; + +// GoService — Deno calls Go for sandboxed I/O and state. +service GoService { + rpc ReadFile(ReadFileRequest) returns (ReadFileResponse); + rpc WriteFile(WriteFileRequest) returns (WriteFileResponse); + rpc ListFiles(ListFilesRequest) returns (ListFilesResponse); + rpc StoreGet(StoreGetRequest) returns (StoreGetResponse); + rpc StoreSet(StoreSetRequest) returns (StoreSetResponse); + rpc Exec(ExecRequest) returns (ExecResponse); + rpc ResolveScheme(SchemeRequest) returns (SchemeResponse); +} + +// DenoService — Go calls Deno for TypeScript lifecycle and rendering. +service DenoService { + rpc OnStart(LifecycleEvent) returns (Ack); + rpc OnStop(LifecycleEvent) returns (Ack); + rpc OnConfigChange(ConfigChangeEvent) returns (Ack); + rpc Render(RenderRequest) returns (RenderResponse); + rpc Eval(EvalRequest) returns (EvalResponse); +} + +// --- GoService: file I/O (go-io Medium) --- + +message ReadFileRequest { + string path = 1; // relative to the Medium sandbox root + string module_code = 2; // initiating module, for permission scoping +} +message ReadFileResponse { + bytes data = 1; + string error = 2; +} +message WriteFileRequest { + string path = 1; + bytes data = 2; + uint32 mode = 3; // octal file mode; 0 = Medium default + string module_code = 4; +} +message WriteFileResponse { + bool ok = 1; + string error = 2; +} +message ListFilesRequest { + string path = 1; + string module_code = 2; +} +message FileEntry { + string name = 1; + bool is_dir = 2; +} +message ListFilesResponse { + repeated FileEntry entries = 1; + string error = 2; +} + +// --- GoService: KV store (go-store Store, group+key) --- + +message StoreGetRequest { + string group = 1; + string key = 2; + string module_code = 3; +} +message StoreGetResponse { + string value = 1; + bool found = 2; + string error = 3; +} +message StoreSetRequest { + string group = 1; + string key = 2; + string value = 3; + string module_code = 4; +} +message StoreSetResponse { + bool ok = 1; + string error = 2; +} + +// --- GoService: process execution (go-process Service) --- + +message ExecRequest { + string cmd = 1; + repeated string args = 2; + string dir = 3; + repeated string env = 4; // KEY=VALUE overrides + string module_code = 5; +} +message ExecResponse { + bytes output = 1; // combined stdout/stderr + int32 exit_code = 2; + string error = 3; +} + +// --- GoService: core:// scheme resolution --- + +message SchemeRequest { + string uri = 1; + string module_code = 2; +} +message SchemeResponse { + string result_json = 1; + string error = 2; +} + +// --- DenoService: lifecycle + rendering (Go -> Deno) --- + +message LifecycleEvent { + string phase = 1; // "start" | "stop" + string reason = 2; +} +message ConfigChangeEvent { + string key = 1; // dotted config key, e.g. "theme.accent" + string value = 2; // JSON-encoded for non-string types +} +message RenderRequest { + string component = 1; + string props = 2; // JSON-encoded props +} +message RenderResponse { + string html = 1; + string error = 2; +} +message EvalRequest { + string expression = 1; +} +message EvalResponse { + string result_json = 1; + string error = 2; +} +message Ack { + bool ok = 1; +} diff --git a/go/pkg/proto/gen/core_sidecar.pb.go b/go/pkg/proto/gen/core_sidecar.pb.go new file mode 100644 index 0000000..5abd7f0 --- /dev/null +++ b/go/pkg/proto/gen/core_sidecar.pb.go @@ -0,0 +1,1447 @@ +// SPDX-License-Identifier: EUPL-1.2 +// +// core_sidecar.proto +// +// CANONICAL contract between CoreGO and CoreDeno (CoreTS). Single source of +// truth: the Go stubs (api/go/pkg/proto/gen/) and the Deno proto-loader BOTH +// derive from this exact file. Spec of record: code/core/go/api/RFC.grpc.md §2. +// +// Go hosts the GoService server; Deno connects as client. +// Deno hosts the DenoService server; Go connects as client. +// +// Message types match the wired subsystems: go-io Medium (file I/O), +// go-store Store (KV, group+key), go-process Service (exec), the core:// +// scheme registry, and the CoreDeno runtime (DenoService lifecycle + render). +// +// Every GoService request carries module_code — the module that initiated the +// call — so Go applies per-module permission scoping. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: core_sidecar.proto + +package sidecarpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ReadFileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` // relative to the Medium sandbox root + ModuleCode string `protobuf:"bytes,2,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` // initiating module, for permission scoping + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadFileRequest) Reset() { + *x = ReadFileRequest{} + mi := &file_core_sidecar_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadFileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadFileRequest) ProtoMessage() {} + +func (x *ReadFileRequest) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadFileRequest.ProtoReflect.Descriptor instead. +func (*ReadFileRequest) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{0} +} + +func (x *ReadFileRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ReadFileRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type ReadFileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadFileResponse) Reset() { + *x = ReadFileResponse{} + mi := &file_core_sidecar_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadFileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadFileResponse) ProtoMessage() {} + +func (x *ReadFileResponse) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadFileResponse.ProtoReflect.Descriptor instead. +func (*ReadFileResponse) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{1} +} + +func (x *ReadFileResponse) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *ReadFileResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type WriteFileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + Mode uint32 `protobuf:"varint,3,opt,name=mode,proto3" json:"mode,omitempty"` // octal file mode; 0 = Medium default + ModuleCode string `protobuf:"bytes,4,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WriteFileRequest) Reset() { + *x = WriteFileRequest{} + mi := &file_core_sidecar_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WriteFileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WriteFileRequest) ProtoMessage() {} + +func (x *WriteFileRequest) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WriteFileRequest.ProtoReflect.Descriptor instead. +func (*WriteFileRequest) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{2} +} + +func (x *WriteFileRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *WriteFileRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *WriteFileRequest) GetMode() uint32 { + if x != nil { + return x.Mode + } + return 0 +} + +func (x *WriteFileRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type WriteFileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WriteFileResponse) Reset() { + *x = WriteFileResponse{} + mi := &file_core_sidecar_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WriteFileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WriteFileResponse) ProtoMessage() {} + +func (x *WriteFileResponse) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WriteFileResponse.ProtoReflect.Descriptor instead. +func (*WriteFileResponse) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{3} +} + +func (x *WriteFileResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *WriteFileResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type ListFilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + ModuleCode string `protobuf:"bytes,2,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListFilesRequest) Reset() { + *x = ListFilesRequest{} + mi := &file_core_sidecar_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListFilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListFilesRequest) ProtoMessage() {} + +func (x *ListFilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListFilesRequest.ProtoReflect.Descriptor instead. +func (*ListFilesRequest) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{4} +} + +func (x *ListFilesRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ListFilesRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type FileEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + IsDir bool `protobuf:"varint,2,opt,name=is_dir,json=isDir,proto3" json:"is_dir,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileEntry) Reset() { + *x = FileEntry{} + mi := &file_core_sidecar_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileEntry) ProtoMessage() {} + +func (x *FileEntry) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileEntry.ProtoReflect.Descriptor instead. +func (*FileEntry) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{5} +} + +func (x *FileEntry) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FileEntry) GetIsDir() bool { + if x != nil { + return x.IsDir + } + return false +} + +type ListFilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*FileEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListFilesResponse) Reset() { + *x = ListFilesResponse{} + mi := &file_core_sidecar_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListFilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListFilesResponse) ProtoMessage() {} + +func (x *ListFilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListFilesResponse.ProtoReflect.Descriptor instead. +func (*ListFilesResponse) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{6} +} + +func (x *ListFilesResponse) GetEntries() []*FileEntry { + if x != nil { + return x.Entries + } + return nil +} + +func (x *ListFilesResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type StoreGetRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Group string `protobuf:"bytes,1,opt,name=group,proto3" json:"group,omitempty"` + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + ModuleCode string `protobuf:"bytes,3,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoreGetRequest) Reset() { + *x = StoreGetRequest{} + mi := &file_core_sidecar_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoreGetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoreGetRequest) ProtoMessage() {} + +func (x *StoreGetRequest) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoreGetRequest.ProtoReflect.Descriptor instead. +func (*StoreGetRequest) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{7} +} + +func (x *StoreGetRequest) GetGroup() string { + if x != nil { + return x.Group + } + return "" +} + +func (x *StoreGetRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *StoreGetRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type StoreGetResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + Found bool `protobuf:"varint,2,opt,name=found,proto3" json:"found,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoreGetResponse) Reset() { + *x = StoreGetResponse{} + mi := &file_core_sidecar_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoreGetResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoreGetResponse) ProtoMessage() {} + +func (x *StoreGetResponse) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoreGetResponse.ProtoReflect.Descriptor instead. +func (*StoreGetResponse) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{8} +} + +func (x *StoreGetResponse) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *StoreGetResponse) GetFound() bool { + if x != nil { + return x.Found + } + return false +} + +func (x *StoreGetResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type StoreSetRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Group string `protobuf:"bytes,1,opt,name=group,proto3" json:"group,omitempty"` + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + Value string `protobuf:"bytes,3,opt,name=value,proto3" json:"value,omitempty"` + ModuleCode string `protobuf:"bytes,4,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoreSetRequest) Reset() { + *x = StoreSetRequest{} + mi := &file_core_sidecar_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoreSetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoreSetRequest) ProtoMessage() {} + +func (x *StoreSetRequest) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoreSetRequest.ProtoReflect.Descriptor instead. +func (*StoreSetRequest) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{9} +} + +func (x *StoreSetRequest) GetGroup() string { + if x != nil { + return x.Group + } + return "" +} + +func (x *StoreSetRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *StoreSetRequest) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *StoreSetRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type StoreSetResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoreSetResponse) Reset() { + *x = StoreSetResponse{} + mi := &file_core_sidecar_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoreSetResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoreSetResponse) ProtoMessage() {} + +func (x *StoreSetResponse) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StoreSetResponse.ProtoReflect.Descriptor instead. +func (*StoreSetResponse) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{10} +} + +func (x *StoreSetResponse) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *StoreSetResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type ExecRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"` + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + Dir string `protobuf:"bytes,3,opt,name=dir,proto3" json:"dir,omitempty"` + Env []string `protobuf:"bytes,4,rep,name=env,proto3" json:"env,omitempty"` // KEY=VALUE overrides + ModuleCode string `protobuf:"bytes,5,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecRequest) Reset() { + *x = ExecRequest{} + mi := &file_core_sidecar_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecRequest) ProtoMessage() {} + +func (x *ExecRequest) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecRequest.ProtoReflect.Descriptor instead. +func (*ExecRequest) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{11} +} + +func (x *ExecRequest) GetCmd() string { + if x != nil { + return x.Cmd + } + return "" +} + +func (x *ExecRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ExecRequest) GetDir() string { + if x != nil { + return x.Dir + } + return "" +} + +func (x *ExecRequest) GetEnv() []string { + if x != nil { + return x.Env + } + return nil +} + +func (x *ExecRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type ExecResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Output []byte `protobuf:"bytes,1,opt,name=output,proto3" json:"output,omitempty"` // combined stdout/stderr + ExitCode int32 `protobuf:"varint,2,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecResponse) Reset() { + *x = ExecResponse{} + mi := &file_core_sidecar_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecResponse) ProtoMessage() {} + +func (x *ExecResponse) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecResponse.ProtoReflect.Descriptor instead. +func (*ExecResponse) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{12} +} + +func (x *ExecResponse) GetOutput() []byte { + if x != nil { + return x.Output + } + return nil +} + +func (x *ExecResponse) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ExecResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type SchemeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Uri string `protobuf:"bytes,1,opt,name=uri,proto3" json:"uri,omitempty"` + ModuleCode string `protobuf:"bytes,2,opt,name=module_code,json=moduleCode,proto3" json:"module_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SchemeRequest) Reset() { + *x = SchemeRequest{} + mi := &file_core_sidecar_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SchemeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SchemeRequest) ProtoMessage() {} + +func (x *SchemeRequest) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SchemeRequest.ProtoReflect.Descriptor instead. +func (*SchemeRequest) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{13} +} + +func (x *SchemeRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *SchemeRequest) GetModuleCode() string { + if x != nil { + return x.ModuleCode + } + return "" +} + +type SchemeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ResultJson string `protobuf:"bytes,1,opt,name=result_json,json=resultJson,proto3" json:"result_json,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SchemeResponse) Reset() { + *x = SchemeResponse{} + mi := &file_core_sidecar_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SchemeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SchemeResponse) ProtoMessage() {} + +func (x *SchemeResponse) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SchemeResponse.ProtoReflect.Descriptor instead. +func (*SchemeResponse) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{14} +} + +func (x *SchemeResponse) GetResultJson() string { + if x != nil { + return x.ResultJson + } + return "" +} + +func (x *SchemeResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type LifecycleEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Phase string `protobuf:"bytes,1,opt,name=phase,proto3" json:"phase,omitempty"` // "start" | "stop" + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LifecycleEvent) Reset() { + *x = LifecycleEvent{} + mi := &file_core_sidecar_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LifecycleEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LifecycleEvent) ProtoMessage() {} + +func (x *LifecycleEvent) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LifecycleEvent.ProtoReflect.Descriptor instead. +func (*LifecycleEvent) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{15} +} + +func (x *LifecycleEvent) GetPhase() string { + if x != nil { + return x.Phase + } + return "" +} + +func (x *LifecycleEvent) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type ConfigChangeEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // dotted config key, e.g. "theme.accent" + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // JSON-encoded for non-string types + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigChangeEvent) Reset() { + *x = ConfigChangeEvent{} + mi := &file_core_sidecar_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigChangeEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigChangeEvent) ProtoMessage() {} + +func (x *ConfigChangeEvent) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConfigChangeEvent.ProtoReflect.Descriptor instead. +func (*ConfigChangeEvent) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{16} +} + +func (x *ConfigChangeEvent) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *ConfigChangeEvent) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type RenderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Component string `protobuf:"bytes,1,opt,name=component,proto3" json:"component,omitempty"` + Props string `protobuf:"bytes,2,opt,name=props,proto3" json:"props,omitempty"` // JSON-encoded props + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderRequest) Reset() { + *x = RenderRequest{} + mi := &file_core_sidecar_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderRequest) ProtoMessage() {} + +func (x *RenderRequest) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenderRequest.ProtoReflect.Descriptor instead. +func (*RenderRequest) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{17} +} + +func (x *RenderRequest) GetComponent() string { + if x != nil { + return x.Component + } + return "" +} + +func (x *RenderRequest) GetProps() string { + if x != nil { + return x.Props + } + return "" +} + +type RenderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Html string `protobuf:"bytes,1,opt,name=html,proto3" json:"html,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderResponse) Reset() { + *x = RenderResponse{} + mi := &file_core_sidecar_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderResponse) ProtoMessage() {} + +func (x *RenderResponse) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenderResponse.ProtoReflect.Descriptor instead. +func (*RenderResponse) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{18} +} + +func (x *RenderResponse) GetHtml() string { + if x != nil { + return x.Html + } + return "" +} + +func (x *RenderResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type EvalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Expression string `protobuf:"bytes,1,opt,name=expression,proto3" json:"expression,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EvalRequest) Reset() { + *x = EvalRequest{} + mi := &file_core_sidecar_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EvalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EvalRequest) ProtoMessage() {} + +func (x *EvalRequest) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EvalRequest.ProtoReflect.Descriptor instead. +func (*EvalRequest) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{19} +} + +func (x *EvalRequest) GetExpression() string { + if x != nil { + return x.Expression + } + return "" +} + +type EvalResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ResultJson string `protobuf:"bytes,1,opt,name=result_json,json=resultJson,proto3" json:"result_json,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EvalResponse) Reset() { + *x = EvalResponse{} + mi := &file_core_sidecar_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EvalResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EvalResponse) ProtoMessage() {} + +func (x *EvalResponse) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EvalResponse.ProtoReflect.Descriptor instead. +func (*EvalResponse) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{20} +} + +func (x *EvalResponse) GetResultJson() string { + if x != nil { + return x.ResultJson + } + return "" +} + +func (x *EvalResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type Ack struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Ack) Reset() { + *x = Ack{} + mi := &file_core_sidecar_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Ack) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Ack) ProtoMessage() {} + +func (x *Ack) ProtoReflect() protoreflect.Message { + mi := &file_core_sidecar_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Ack.ProtoReflect.Descriptor instead. +func (*Ack) Descriptor() ([]byte, []int) { + return file_core_sidecar_proto_rawDescGZIP(), []int{21} +} + +func (x *Ack) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +var File_core_sidecar_proto protoreflect.FileDescriptor + +const file_core_sidecar_proto_rawDesc = "" + + "\n" + + "\x12core_sidecar.proto\x12\fcore.sidecar\"F\n" + + "\x0fReadFileRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1f\n" + + "\vmodule_code\x18\x02 \x01(\tR\n" + + "moduleCode\"<\n" + + "\x10ReadFileResponse\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"o\n" + + "\x10WriteFileRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + + "\x04data\x18\x02 \x01(\fR\x04data\x12\x12\n" + + "\x04mode\x18\x03 \x01(\rR\x04mode\x12\x1f\n" + + "\vmodule_code\x18\x04 \x01(\tR\n" + + "moduleCode\"9\n" + + "\x11WriteFileResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"G\n" + + "\x10ListFilesRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1f\n" + + "\vmodule_code\x18\x02 \x01(\tR\n" + + "moduleCode\"6\n" + + "\tFileEntry\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x15\n" + + "\x06is_dir\x18\x02 \x01(\bR\x05isDir\"\\\n" + + "\x11ListFilesResponse\x121\n" + + "\aentries\x18\x01 \x03(\v2\x17.core.sidecar.FileEntryR\aentries\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"Z\n" + + "\x0fStoreGetRequest\x12\x14\n" + + "\x05group\x18\x01 \x01(\tR\x05group\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x12\x1f\n" + + "\vmodule_code\x18\x03 \x01(\tR\n" + + "moduleCode\"T\n" + + "\x10StoreGetResponse\x12\x14\n" + + "\x05value\x18\x01 \x01(\tR\x05value\x12\x14\n" + + "\x05found\x18\x02 \x01(\bR\x05found\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"p\n" + + "\x0fStoreSetRequest\x12\x14\n" + + "\x05group\x18\x01 \x01(\tR\x05group\x12\x10\n" + + "\x03key\x18\x02 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x03 \x01(\tR\x05value\x12\x1f\n" + + "\vmodule_code\x18\x04 \x01(\tR\n" + + "moduleCode\"8\n" + + "\x10StoreSetResponse\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"x\n" + + "\vExecRequest\x12\x10\n" + + "\x03cmd\x18\x01 \x01(\tR\x03cmd\x12\x12\n" + + "\x04args\x18\x02 \x03(\tR\x04args\x12\x10\n" + + "\x03dir\x18\x03 \x01(\tR\x03dir\x12\x10\n" + + "\x03env\x18\x04 \x03(\tR\x03env\x12\x1f\n" + + "\vmodule_code\x18\x05 \x01(\tR\n" + + "moduleCode\"Y\n" + + "\fExecResponse\x12\x16\n" + + "\x06output\x18\x01 \x01(\fR\x06output\x12\x1b\n" + + "\texit_code\x18\x02 \x01(\x05R\bexitCode\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"B\n" + + "\rSchemeRequest\x12\x10\n" + + "\x03uri\x18\x01 \x01(\tR\x03uri\x12\x1f\n" + + "\vmodule_code\x18\x02 \x01(\tR\n" + + "moduleCode\"G\n" + + "\x0eSchemeResponse\x12\x1f\n" + + "\vresult_json\x18\x01 \x01(\tR\n" + + "resultJson\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\">\n" + + "\x0eLifecycleEvent\x12\x14\n" + + "\x05phase\x18\x01 \x01(\tR\x05phase\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\";\n" + + "\x11ConfigChangeEvent\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\"C\n" + + "\rRenderRequest\x12\x1c\n" + + "\tcomponent\x18\x01 \x01(\tR\tcomponent\x12\x14\n" + + "\x05props\x18\x02 \x01(\tR\x05props\":\n" + + "\x0eRenderResponse\x12\x12\n" + + "\x04html\x18\x01 \x01(\tR\x04html\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"-\n" + + "\vEvalRequest\x12\x1e\n" + + "\n" + + "expression\x18\x01 \x01(\tR\n" + + "expression\"E\n" + + "\fEvalResponse\x12\x1f\n" + + "\vresult_json\x18\x01 \x01(\tR\n" + + "resultJson\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"\x15\n" + + "\x03Ack\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok2\x93\x04\n" + + "\tGoService\x12I\n" + + "\bReadFile\x12\x1d.core.sidecar.ReadFileRequest\x1a\x1e.core.sidecar.ReadFileResponse\x12L\n" + + "\tWriteFile\x12\x1e.core.sidecar.WriteFileRequest\x1a\x1f.core.sidecar.WriteFileResponse\x12L\n" + + "\tListFiles\x12\x1e.core.sidecar.ListFilesRequest\x1a\x1f.core.sidecar.ListFilesResponse\x12I\n" + + "\bStoreGet\x12\x1d.core.sidecar.StoreGetRequest\x1a\x1e.core.sidecar.StoreGetResponse\x12I\n" + + "\bStoreSet\x12\x1d.core.sidecar.StoreSetRequest\x1a\x1e.core.sidecar.StoreSetResponse\x12=\n" + + "\x04Exec\x12\x19.core.sidecar.ExecRequest\x1a\x1a.core.sidecar.ExecResponse\x12J\n" + + "\rResolveScheme\x12\x1b.core.sidecar.SchemeRequest\x1a\x1c.core.sidecar.SchemeResponse2\xce\x02\n" + + "\vDenoService\x12:\n" + + "\aOnStart\x12\x1c.core.sidecar.LifecycleEvent\x1a\x11.core.sidecar.Ack\x129\n" + + "\x06OnStop\x12\x1c.core.sidecar.LifecycleEvent\x1a\x11.core.sidecar.Ack\x12D\n" + + "\x0eOnConfigChange\x12\x1f.core.sidecar.ConfigChangeEvent\x1a\x11.core.sidecar.Ack\x12C\n" + + "\x06Render\x12\x1b.core.sidecar.RenderRequest\x1a\x1c.core.sidecar.RenderResponse\x12=\n" + + "\x04Eval\x12\x19.core.sidecar.EvalRequest\x1a\x1a.core.sidecar.EvalResponseB*Z(dappco.re/go/api/pkg/proto/gen;sidecarpbb\x06proto3" + +var ( + file_core_sidecar_proto_rawDescOnce sync.Once + file_core_sidecar_proto_rawDescData []byte +) + +func file_core_sidecar_proto_rawDescGZIP() []byte { + file_core_sidecar_proto_rawDescOnce.Do(func() { + file_core_sidecar_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_core_sidecar_proto_rawDesc), len(file_core_sidecar_proto_rawDesc))) + }) + return file_core_sidecar_proto_rawDescData +} + +var file_core_sidecar_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_core_sidecar_proto_goTypes = []any{ + (*ReadFileRequest)(nil), // 0: core.sidecar.ReadFileRequest + (*ReadFileResponse)(nil), // 1: core.sidecar.ReadFileResponse + (*WriteFileRequest)(nil), // 2: core.sidecar.WriteFileRequest + (*WriteFileResponse)(nil), // 3: core.sidecar.WriteFileResponse + (*ListFilesRequest)(nil), // 4: core.sidecar.ListFilesRequest + (*FileEntry)(nil), // 5: core.sidecar.FileEntry + (*ListFilesResponse)(nil), // 6: core.sidecar.ListFilesResponse + (*StoreGetRequest)(nil), // 7: core.sidecar.StoreGetRequest + (*StoreGetResponse)(nil), // 8: core.sidecar.StoreGetResponse + (*StoreSetRequest)(nil), // 9: core.sidecar.StoreSetRequest + (*StoreSetResponse)(nil), // 10: core.sidecar.StoreSetResponse + (*ExecRequest)(nil), // 11: core.sidecar.ExecRequest + (*ExecResponse)(nil), // 12: core.sidecar.ExecResponse + (*SchemeRequest)(nil), // 13: core.sidecar.SchemeRequest + (*SchemeResponse)(nil), // 14: core.sidecar.SchemeResponse + (*LifecycleEvent)(nil), // 15: core.sidecar.LifecycleEvent + (*ConfigChangeEvent)(nil), // 16: core.sidecar.ConfigChangeEvent + (*RenderRequest)(nil), // 17: core.sidecar.RenderRequest + (*RenderResponse)(nil), // 18: core.sidecar.RenderResponse + (*EvalRequest)(nil), // 19: core.sidecar.EvalRequest + (*EvalResponse)(nil), // 20: core.sidecar.EvalResponse + (*Ack)(nil), // 21: core.sidecar.Ack +} +var file_core_sidecar_proto_depIdxs = []int32{ + 5, // 0: core.sidecar.ListFilesResponse.entries:type_name -> core.sidecar.FileEntry + 0, // 1: core.sidecar.GoService.ReadFile:input_type -> core.sidecar.ReadFileRequest + 2, // 2: core.sidecar.GoService.WriteFile:input_type -> core.sidecar.WriteFileRequest + 4, // 3: core.sidecar.GoService.ListFiles:input_type -> core.sidecar.ListFilesRequest + 7, // 4: core.sidecar.GoService.StoreGet:input_type -> core.sidecar.StoreGetRequest + 9, // 5: core.sidecar.GoService.StoreSet:input_type -> core.sidecar.StoreSetRequest + 11, // 6: core.sidecar.GoService.Exec:input_type -> core.sidecar.ExecRequest + 13, // 7: core.sidecar.GoService.ResolveScheme:input_type -> core.sidecar.SchemeRequest + 15, // 8: core.sidecar.DenoService.OnStart:input_type -> core.sidecar.LifecycleEvent + 15, // 9: core.sidecar.DenoService.OnStop:input_type -> core.sidecar.LifecycleEvent + 16, // 10: core.sidecar.DenoService.OnConfigChange:input_type -> core.sidecar.ConfigChangeEvent + 17, // 11: core.sidecar.DenoService.Render:input_type -> core.sidecar.RenderRequest + 19, // 12: core.sidecar.DenoService.Eval:input_type -> core.sidecar.EvalRequest + 1, // 13: core.sidecar.GoService.ReadFile:output_type -> core.sidecar.ReadFileResponse + 3, // 14: core.sidecar.GoService.WriteFile:output_type -> core.sidecar.WriteFileResponse + 6, // 15: core.sidecar.GoService.ListFiles:output_type -> core.sidecar.ListFilesResponse + 8, // 16: core.sidecar.GoService.StoreGet:output_type -> core.sidecar.StoreGetResponse + 10, // 17: core.sidecar.GoService.StoreSet:output_type -> core.sidecar.StoreSetResponse + 12, // 18: core.sidecar.GoService.Exec:output_type -> core.sidecar.ExecResponse + 14, // 19: core.sidecar.GoService.ResolveScheme:output_type -> core.sidecar.SchemeResponse + 21, // 20: core.sidecar.DenoService.OnStart:output_type -> core.sidecar.Ack + 21, // 21: core.sidecar.DenoService.OnStop:output_type -> core.sidecar.Ack + 21, // 22: core.sidecar.DenoService.OnConfigChange:output_type -> core.sidecar.Ack + 18, // 23: core.sidecar.DenoService.Render:output_type -> core.sidecar.RenderResponse + 20, // 24: core.sidecar.DenoService.Eval:output_type -> core.sidecar.EvalResponse + 13, // [13:25] is the sub-list for method output_type + 1, // [1:13] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_core_sidecar_proto_init() } +func file_core_sidecar_proto_init() { + if File_core_sidecar_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_core_sidecar_proto_rawDesc), len(file_core_sidecar_proto_rawDesc)), + NumEnums: 0, + NumMessages: 22, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_core_sidecar_proto_goTypes, + DependencyIndexes: file_core_sidecar_proto_depIdxs, + MessageInfos: file_core_sidecar_proto_msgTypes, + }.Build() + File_core_sidecar_proto = out.File + file_core_sidecar_proto_goTypes = nil + file_core_sidecar_proto_depIdxs = nil +} diff --git a/go/pkg/proto/gen/core_sidecar_grpc.pb.go b/go/pkg/proto/gen/core_sidecar_grpc.pb.go new file mode 100644 index 0000000..6264770 --- /dev/null +++ b/go/pkg/proto/gen/core_sidecar_grpc.pb.go @@ -0,0 +1,629 @@ +// SPDX-License-Identifier: EUPL-1.2 +// +// core_sidecar.proto +// +// CANONICAL contract between CoreGO and CoreDeno (CoreTS). Single source of +// truth: the Go stubs (api/go/pkg/proto/gen/) and the Deno proto-loader BOTH +// derive from this exact file. Spec of record: code/core/go/api/RFC.grpc.md §2. +// +// Go hosts the GoService server; Deno connects as client. +// Deno hosts the DenoService server; Go connects as client. +// +// Message types match the wired subsystems: go-io Medium (file I/O), +// go-store Store (KV, group+key), go-process Service (exec), the core:// +// scheme registry, and the CoreDeno runtime (DenoService lifecycle + render). +// +// Every GoService request carries module_code — the module that initiated the +// call — so Go applies per-module permission scoping. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v5.29.3 +// source: core_sidecar.proto + +package sidecarpb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + GoService_ReadFile_FullMethodName = "/core.sidecar.GoService/ReadFile" + GoService_WriteFile_FullMethodName = "/core.sidecar.GoService/WriteFile" + GoService_ListFiles_FullMethodName = "/core.sidecar.GoService/ListFiles" + GoService_StoreGet_FullMethodName = "/core.sidecar.GoService/StoreGet" + GoService_StoreSet_FullMethodName = "/core.sidecar.GoService/StoreSet" + GoService_Exec_FullMethodName = "/core.sidecar.GoService/Exec" + GoService_ResolveScheme_FullMethodName = "/core.sidecar.GoService/ResolveScheme" +) + +// GoServiceClient is the client API for GoService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// GoService — Deno calls Go for sandboxed I/O and state. +type GoServiceClient interface { + ReadFile(ctx context.Context, in *ReadFileRequest, opts ...grpc.CallOption) (*ReadFileResponse, error) + WriteFile(ctx context.Context, in *WriteFileRequest, opts ...grpc.CallOption) (*WriteFileResponse, error) + ListFiles(ctx context.Context, in *ListFilesRequest, opts ...grpc.CallOption) (*ListFilesResponse, error) + StoreGet(ctx context.Context, in *StoreGetRequest, opts ...grpc.CallOption) (*StoreGetResponse, error) + StoreSet(ctx context.Context, in *StoreSetRequest, opts ...grpc.CallOption) (*StoreSetResponse, error) + Exec(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (*ExecResponse, error) + ResolveScheme(ctx context.Context, in *SchemeRequest, opts ...grpc.CallOption) (*SchemeResponse, error) +} + +type goServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewGoServiceClient(cc grpc.ClientConnInterface) GoServiceClient { + return &goServiceClient{cc} +} + +func (c *goServiceClient) ReadFile(ctx context.Context, in *ReadFileRequest, opts ...grpc.CallOption) (*ReadFileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReadFileResponse) + err := c.cc.Invoke(ctx, GoService_ReadFile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *goServiceClient) WriteFile(ctx context.Context, in *WriteFileRequest, opts ...grpc.CallOption) (*WriteFileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WriteFileResponse) + err := c.cc.Invoke(ctx, GoService_WriteFile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *goServiceClient) ListFiles(ctx context.Context, in *ListFilesRequest, opts ...grpc.CallOption) (*ListFilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListFilesResponse) + err := c.cc.Invoke(ctx, GoService_ListFiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *goServiceClient) StoreGet(ctx context.Context, in *StoreGetRequest, opts ...grpc.CallOption) (*StoreGetResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StoreGetResponse) + err := c.cc.Invoke(ctx, GoService_StoreGet_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *goServiceClient) StoreSet(ctx context.Context, in *StoreSetRequest, opts ...grpc.CallOption) (*StoreSetResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StoreSetResponse) + err := c.cc.Invoke(ctx, GoService_StoreSet_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *goServiceClient) Exec(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (*ExecResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExecResponse) + err := c.cc.Invoke(ctx, GoService_Exec_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *goServiceClient) ResolveScheme(ctx context.Context, in *SchemeRequest, opts ...grpc.CallOption) (*SchemeResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SchemeResponse) + err := c.cc.Invoke(ctx, GoService_ResolveScheme_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// GoServiceServer is the server API for GoService service. +// All implementations must embed UnimplementedGoServiceServer +// for forward compatibility. +// +// GoService — Deno calls Go for sandboxed I/O and state. +type GoServiceServer interface { + ReadFile(context.Context, *ReadFileRequest) (*ReadFileResponse, error) + WriteFile(context.Context, *WriteFileRequest) (*WriteFileResponse, error) + ListFiles(context.Context, *ListFilesRequest) (*ListFilesResponse, error) + StoreGet(context.Context, *StoreGetRequest) (*StoreGetResponse, error) + StoreSet(context.Context, *StoreSetRequest) (*StoreSetResponse, error) + Exec(context.Context, *ExecRequest) (*ExecResponse, error) + ResolveScheme(context.Context, *SchemeRequest) (*SchemeResponse, error) + mustEmbedUnimplementedGoServiceServer() +} + +// UnimplementedGoServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedGoServiceServer struct{} + +func (UnimplementedGoServiceServer) ReadFile(context.Context, *ReadFileRequest) (*ReadFileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ReadFile not implemented") +} +func (UnimplementedGoServiceServer) WriteFile(context.Context, *WriteFileRequest) (*WriteFileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method WriteFile not implemented") +} +func (UnimplementedGoServiceServer) ListFiles(context.Context, *ListFilesRequest) (*ListFilesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListFiles not implemented") +} +func (UnimplementedGoServiceServer) StoreGet(context.Context, *StoreGetRequest) (*StoreGetResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StoreGet not implemented") +} +func (UnimplementedGoServiceServer) StoreSet(context.Context, *StoreSetRequest) (*StoreSetResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StoreSet not implemented") +} +func (UnimplementedGoServiceServer) Exec(context.Context, *ExecRequest) (*ExecResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Exec not implemented") +} +func (UnimplementedGoServiceServer) ResolveScheme(context.Context, *SchemeRequest) (*SchemeResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ResolveScheme not implemented") +} +func (UnimplementedGoServiceServer) mustEmbedUnimplementedGoServiceServer() {} +func (UnimplementedGoServiceServer) testEmbeddedByValue() {} + +// UnsafeGoServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to GoServiceServer will +// result in compilation errors. +type UnsafeGoServiceServer interface { + mustEmbedUnimplementedGoServiceServer() +} + +func RegisterGoServiceServer(s grpc.ServiceRegistrar, srv GoServiceServer) { + // If the following call panics, it indicates UnimplementedGoServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&GoService_ServiceDesc, srv) +} + +func _GoService_ReadFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReadFileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GoServiceServer).ReadFile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GoService_ReadFile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GoServiceServer).ReadFile(ctx, req.(*ReadFileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GoService_WriteFile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WriteFileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GoServiceServer).WriteFile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GoService_WriteFile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GoServiceServer).WriteFile(ctx, req.(*WriteFileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GoService_ListFiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListFilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GoServiceServer).ListFiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GoService_ListFiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GoServiceServer).ListFiles(ctx, req.(*ListFilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GoService_StoreGet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StoreGetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GoServiceServer).StoreGet(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GoService_StoreGet_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GoServiceServer).StoreGet(ctx, req.(*StoreGetRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GoService_StoreSet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StoreSetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GoServiceServer).StoreSet(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GoService_StoreSet_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GoServiceServer).StoreSet(ctx, req.(*StoreSetRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GoService_Exec_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExecRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GoServiceServer).Exec(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GoService_Exec_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GoServiceServer).Exec(ctx, req.(*ExecRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GoService_ResolveScheme_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SchemeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GoServiceServer).ResolveScheme(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GoService_ResolveScheme_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GoServiceServer).ResolveScheme(ctx, req.(*SchemeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// GoService_ServiceDesc is the grpc.ServiceDesc for GoService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var GoService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "core.sidecar.GoService", + HandlerType: (*GoServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ReadFile", + Handler: _GoService_ReadFile_Handler, + }, + { + MethodName: "WriteFile", + Handler: _GoService_WriteFile_Handler, + }, + { + MethodName: "ListFiles", + Handler: _GoService_ListFiles_Handler, + }, + { + MethodName: "StoreGet", + Handler: _GoService_StoreGet_Handler, + }, + { + MethodName: "StoreSet", + Handler: _GoService_StoreSet_Handler, + }, + { + MethodName: "Exec", + Handler: _GoService_Exec_Handler, + }, + { + MethodName: "ResolveScheme", + Handler: _GoService_ResolveScheme_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "core_sidecar.proto", +} + +const ( + DenoService_OnStart_FullMethodName = "/core.sidecar.DenoService/OnStart" + DenoService_OnStop_FullMethodName = "/core.sidecar.DenoService/OnStop" + DenoService_OnConfigChange_FullMethodName = "/core.sidecar.DenoService/OnConfigChange" + DenoService_Render_FullMethodName = "/core.sidecar.DenoService/Render" + DenoService_Eval_FullMethodName = "/core.sidecar.DenoService/Eval" +) + +// DenoServiceClient is the client API for DenoService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// DenoService — Go calls Deno for TypeScript lifecycle and rendering. +type DenoServiceClient interface { + OnStart(ctx context.Context, in *LifecycleEvent, opts ...grpc.CallOption) (*Ack, error) + OnStop(ctx context.Context, in *LifecycleEvent, opts ...grpc.CallOption) (*Ack, error) + OnConfigChange(ctx context.Context, in *ConfigChangeEvent, opts ...grpc.CallOption) (*Ack, error) + Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) + Eval(ctx context.Context, in *EvalRequest, opts ...grpc.CallOption) (*EvalResponse, error) +} + +type denoServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewDenoServiceClient(cc grpc.ClientConnInterface) DenoServiceClient { + return &denoServiceClient{cc} +} + +func (c *denoServiceClient) OnStart(ctx context.Context, in *LifecycleEvent, opts ...grpc.CallOption) (*Ack, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Ack) + err := c.cc.Invoke(ctx, DenoService_OnStart_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *denoServiceClient) OnStop(ctx context.Context, in *LifecycleEvent, opts ...grpc.CallOption) (*Ack, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Ack) + err := c.cc.Invoke(ctx, DenoService_OnStop_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *denoServiceClient) OnConfigChange(ctx context.Context, in *ConfigChangeEvent, opts ...grpc.CallOption) (*Ack, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Ack) + err := c.cc.Invoke(ctx, DenoService_OnConfigChange_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *denoServiceClient) Render(ctx context.Context, in *RenderRequest, opts ...grpc.CallOption) (*RenderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RenderResponse) + err := c.cc.Invoke(ctx, DenoService_Render_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *denoServiceClient) Eval(ctx context.Context, in *EvalRequest, opts ...grpc.CallOption) (*EvalResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EvalResponse) + err := c.cc.Invoke(ctx, DenoService_Eval_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// DenoServiceServer is the server API for DenoService service. +// All implementations must embed UnimplementedDenoServiceServer +// for forward compatibility. +// +// DenoService — Go calls Deno for TypeScript lifecycle and rendering. +type DenoServiceServer interface { + OnStart(context.Context, *LifecycleEvent) (*Ack, error) + OnStop(context.Context, *LifecycleEvent) (*Ack, error) + OnConfigChange(context.Context, *ConfigChangeEvent) (*Ack, error) + Render(context.Context, *RenderRequest) (*RenderResponse, error) + Eval(context.Context, *EvalRequest) (*EvalResponse, error) + mustEmbedUnimplementedDenoServiceServer() +} + +// UnimplementedDenoServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDenoServiceServer struct{} + +func (UnimplementedDenoServiceServer) OnStart(context.Context, *LifecycleEvent) (*Ack, error) { + return nil, status.Error(codes.Unimplemented, "method OnStart not implemented") +} +func (UnimplementedDenoServiceServer) OnStop(context.Context, *LifecycleEvent) (*Ack, error) { + return nil, status.Error(codes.Unimplemented, "method OnStop not implemented") +} +func (UnimplementedDenoServiceServer) OnConfigChange(context.Context, *ConfigChangeEvent) (*Ack, error) { + return nil, status.Error(codes.Unimplemented, "method OnConfigChange not implemented") +} +func (UnimplementedDenoServiceServer) Render(context.Context, *RenderRequest) (*RenderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Render not implemented") +} +func (UnimplementedDenoServiceServer) Eval(context.Context, *EvalRequest) (*EvalResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Eval not implemented") +} +func (UnimplementedDenoServiceServer) mustEmbedUnimplementedDenoServiceServer() {} +func (UnimplementedDenoServiceServer) testEmbeddedByValue() {} + +// UnsafeDenoServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to DenoServiceServer will +// result in compilation errors. +type UnsafeDenoServiceServer interface { + mustEmbedUnimplementedDenoServiceServer() +} + +func RegisterDenoServiceServer(s grpc.ServiceRegistrar, srv DenoServiceServer) { + // If the following call panics, it indicates UnimplementedDenoServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&DenoService_ServiceDesc, srv) +} + +func _DenoService_OnStart_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LifecycleEvent) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DenoServiceServer).OnStart(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DenoService_OnStart_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DenoServiceServer).OnStart(ctx, req.(*LifecycleEvent)) + } + return interceptor(ctx, in, info, handler) +} + +func _DenoService_OnStop_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LifecycleEvent) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DenoServiceServer).OnStop(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DenoService_OnStop_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DenoServiceServer).OnStop(ctx, req.(*LifecycleEvent)) + } + return interceptor(ctx, in, info, handler) +} + +func _DenoService_OnConfigChange_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ConfigChangeEvent) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DenoServiceServer).OnConfigChange(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DenoService_OnConfigChange_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DenoServiceServer).OnConfigChange(ctx, req.(*ConfigChangeEvent)) + } + return interceptor(ctx, in, info, handler) +} + +func _DenoService_Render_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RenderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DenoServiceServer).Render(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DenoService_Render_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DenoServiceServer).Render(ctx, req.(*RenderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DenoService_Eval_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EvalRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DenoServiceServer).Eval(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DenoService_Eval_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DenoServiceServer).Eval(ctx, req.(*EvalRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// DenoService_ServiceDesc is the grpc.ServiceDesc for DenoService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var DenoService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "core.sidecar.DenoService", + HandlerType: (*DenoServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "OnStart", + Handler: _DenoService_OnStart_Handler, + }, + { + MethodName: "OnStop", + Handler: _DenoService_OnStop_Handler, + }, + { + MethodName: "OnConfigChange", + Handler: _DenoService_OnConfigChange_Handler, + }, + { + MethodName: "Render", + Handler: _DenoService_Render_Handler, + }, + { + MethodName: "Eval", + Handler: _DenoService_Eval_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "core_sidecar.proto", +} From 9be3d2394867d0928cfccaf977a9fdb43f3e08d3 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 21 Jun 2026 12:11:18 +0100 Subject: [PATCH 3/5] fix(chat_completions): consume inference TextModel.Err() core.Result go-inference flipped TextModel/Backend to the core.Result idiom; the chat-completions handler's model.Err() probes now check !r.OK. Co-Authored-By: Virgil --- go/chat_completions.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/go/chat_completions.go b/go/chat_completions.go index a77b566..ffc5ad9 100644 --- a/go/chat_completions.go +++ b/go/chat_completions.go @@ -868,7 +868,7 @@ func (h *chatCompletionsHandler) serveNonStreaming(c *gin.Context, model inferen for tok := range model.Chat(ctx, messages, opts...) { extractor.Process(tok) } - if err := model.Err(); err != nil { + if err := model.Err(); !err.OK { if core.Contains(core.Lower(err.Error()), "loading") { writeChatCompletionError(c, http.StatusServiceUnavailable, "model_loading", "model", err.Error(), "") return @@ -1006,7 +1006,7 @@ func (h *chatCompletionsHandler) serveStreaming(c *gin.Context, model inference. } } - if err := model.Err(); err != nil { + if err := model.Err(); !err.OK { if !streamStarted { if core.Contains(core.Lower(err.Error()), "loading") { writeChatCompletionError(c, http.StatusServiceUnavailable, "model_loading", "model", err.Error(), "") @@ -1019,7 +1019,7 @@ func (h *chatCompletionsHandler) serveStreaming(c *gin.Context, model inference. finishReason := "stop" metrics := model.Metrics() - if err := model.Err(); err != nil { + if err := model.Err(); !err.OK { finishReason = "error" } if finishReason != "error" && isTokenLengthCapReached(req.MaxTokens, metrics.GeneratedTokens) { From 73ba3c9611555a858868a87db943602526f3f722 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 27 Jun 2026 13:43:36 +0100 Subject: [PATCH 4/5] chore: remove stale agent state-dump docs Co-Authored-By: Virgil --- GOAL.md | 797 -------------------------------------------------------- 1 file changed, 797 deletions(-) delete mode 100644 GOAL.md diff --git a/GOAL.md b/GOAL.md deleted file mode 100644 index d19a297..0000000 --- a/GOAL.md +++ /dev/null @@ -1,797 +0,0 @@ -# Sonar sweeps — core-api findings - -707 findings across 26 rules. One rule per commit; fix every line listed under each rule. - -## BLOCKER - -### php:S2068 — Credentials should not be hard-coded (2×, vulnerability) - -- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:152` — Detected URI with password, review this potentially hardcoded credential. -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:310` — Detected URI with password, review this potentially hardcoded credential. - -### php:S6418 — Secrets should not be hard-coded (1×, vulnerability) - -- `src/php/src/Api/Documentation/Examples/CommonExamples.php:169` — 'API-Key' detected in this expression, review this potentially hard-coded secret. - -## CRITICAL - -### go:S1192 — String literals should not be duplicated (371×, code smell) - -- `api_describable_test.go:126` — Define a constant instead of duplicating this literal "/api/widgets" 4 times. -- `api_describable_test.go:150` — Define a constant instead of duplicating this literal "expected tags array, got %T" 3 times. -- `api_renderable_test.go:72` — Define a constant instead of duplicating this literal "/api/widgets" 4 times. -- `api_renderable_test.go:107` — Define a constant instead of duplicating this literal "x-render-hints" 6 times. -- `api_test.go:24` — Define a constant instead of duplicating this literal "health-extra" 3 times. -- `api_test.go:132` — Define a constant instead of duplicating this literal "expected 200, got %d" 3 times. -- `api_test.go:137` — Define a constant instead of duplicating this literal "unmarshal error: %v" 3 times. -- `authentik_integration_test.go:149` — Define a constant instead of duplicating this literal "/v1/whoami" 4 times. -- `authentik_test.go:21` — Define a constant instead of duplicating this literal "alice@example.com" 4 times. -- `authentik_test.go:22` — Define a constant instead of duplicating this literal "Alice Smith" 3 times. -- `authentik_test.go:23` — Define a constant instead of duplicating this literal "abc-123" 3 times. -- `authentik_test.go:26` — Define a constant instead of duplicating this literal "tok.en.here" 3 times. -- `authentik_test.go:30` — Define a constant instead of duplicating this literal "expected Username=%q, got %q" 3 times. -- `authentik_test.go:33` — Define a constant instead of duplicating this literal "expected Email=%q, got %q" 3 times. -- `authentik_test.go:75` — Define a constant instead of duplicating this literal "https://auth.example.com" 3 times. -- `authentik_test.go:76` — Define a constant instead of duplicating this literal "my-client" 3 times. -- `authentik_test.go:101` — Define a constant instead of duplicating this literal "unexpected error: %v" 3 times. -- `authentik_test.go:147` — Define a constant instead of duplicating this literal "/v1/check" 6 times. -- `authentik_test.go:148` — Define a constant instead of duplicating this literal "X-authentik-username" 7 times. -- `authentik_test.go:149` — Define a constant instead of duplicating this literal "bob@example.com" 3 times. -- `authentik_test.go:149` — Define a constant instead of duplicating this literal "X-authentik-email" 4 times. -- `authentik_test.go:150` — Define a constant instead of duplicating this literal "Bob Jones" 3 times. -- `authentik_test.go:151` — Define a constant instead of duplicating this literal "uid-456" 3 times. -- `authentik_test.go:152` — Define a constant instead of duplicating this literal "jwt.tok.en" 3 times. -- `authentik_test.go:153` — Define a constant instead of duplicating this literal "X-authentik-groups" 4 times. -- `authentik_test.go:158` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times. -- `authentik_test.go:359` — Define a constant instead of duplicating this literal "carol@example.com" 3 times. -- `authentik_test.go:420` — Define a constant instead of duplicating this literal "/v1/protected/data" 3 times. -- `authz_test.go:67` — Define a constant instead of duplicating this literal "/stub/*" 5 times. -- `authz_test.go:75` — Define a constant instead of duplicating this literal "/stub/ping" 6 times. -- `bridge.go:389` — Define a constant instead of duplicating this literal "ToolBridge.Validate" 3 times. -- `bridge.go:420` — Define a constant instead of duplicating this literal "ToolBridge.ValidateResponse" 4 times. -- `bridge.go:467` — Define a constant instead of duplicating this literal "ToolBridge.ValidateSchema" 18 times. -- `bridge_test.go:24` — Define a constant instead of duplicating this literal "/tools" 32 times. -- `bridge_test.go:45` — Define a constant instead of duplicating this literal "/tools/file_read" 8 times. -- `bridge_test.go:53` — Define a constant instead of duplicating this literal "unmarshal error: %v" 23 times. -- `bridge_test.go:56` — Define a constant instead of duplicating this literal "expected Data=%q, got %q" 3 times. -- `bridge_test.go:77` — Define a constant instead of duplicating this literal "/api/v1/tools" 5 times. -- `bridge_test.go:252` — Define a constant instead of duplicating this literal "Read a file from disk" 12 times. -- `bridge_test.go:378` — Define a constant instead of duplicating this literal "expected 200, got %d" 8 times. -- `bridge_test.go:385` — Define a constant instead of duplicating this literal "/tmp/file.txt" 3 times. -- `bridge_test.go:426` — Define a constant instead of duplicating this literal "expected Success=true" 4 times. -- `bridge_test.go:469` — Define a constant instead of duplicating this literal "expected Success=false" 11 times. -- `bridge_test.go:493` — Define a constant instead of duplicating this literal "should not run" 9 times. -- `bridge_test.go:504` — Define a constant instead of duplicating this literal "expected 400, got %d" 8 times. -- `bridge_test.go:515` — Define a constant instead of duplicating this literal "expected invalid_request_body error, got %#v" 13 times. -- `bridge_test.go:646` — Define a constant instead of duplicating this literal "Publish an item" 3 times. -- `bridge_test.go:666` — Define a constant instead of duplicating this literal "/tools/publish_item" 3 times. -- `bridge_test.go:738` — Define a constant instead of duplicating this literal "^[A-Z]+$" 3 times. -- `bridge_test.go:1015` — Define a constant instead of duplicating this literal "/v1/tools" 4 times. -- `bridge_test.go:1135` — Define a constant instead of duplicating this literal "Validate array input" 3 times. -- `bridge_test.go:1154` — Define a constant instead of duplicating this literal "/tools/tags" 3 times. -- `bridge_test.go:1259` — Define a constant instead of duplicating this literal "Validate numeric input" 3 times. -- `bridge_test.go:1277` — Define a constant instead of duplicating this literal "/tools/score" 3 times. -- `brotli.go:59` — Define a constant instead of duplicating this literal "Content-Encoding" 3 times. -- `brotli.go:75` — Define a constant instead of duplicating this literal "Content-Length" 3 times. -- `brotli_test.go:24` — Define a constant instead of duplicating this literal "/stub/ping" 5 times. -- `brotli_test.go:25` — Define a constant instead of duplicating this literal "Accept-Encoding" 4 times. -- `brotli_test.go:29` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times. -- `brotli_test.go:32` — Define a constant instead of duplicating this literal "Content-Encoding" 5 times. -- `cache.go:240` — Define a constant instead of duplicating this literal "X-Request-ID" 3 times. -- `cache_control_test.go:27` — Define a constant instead of duplicating this literal "/items/{id}" 4 times. -- `cache_control_test.go:28` — Define a constant instead of duplicating this literal "public, max-age=60" 9 times. -- `cache_control_test.go:39` — Define a constant instead of duplicating this literal "GET /v1/items/:id" 5 times. -- `cache_control_test.go:123` — Define a constant instead of duplicating this literal "/v1/items/:id" 4 times. -- `cache_control_test.go:128` — Define a constant instead of duplicating this literal "/v1/items/123" 4 times. -- `cache_control_test.go:131` — Define a constant instead of duplicating this literal "Cache-Control" 6 times. -- `cache_test.go:27` — Define a constant instead of duplicating this literal "/cache" 3 times. -- `cache_test.go:72` — Define a constant instead of duplicating this literal "/cache/counter" 17 times. -- `cache_test.go:76` — Define a constant instead of duplicating this literal "expected 200, got %d" 11 times. -- `cache_test.go:80` — Define a constant instead of duplicating this literal "call-1" 12 times. -- `cache_test.go:81` — Define a constant instead of duplicating this literal "expected body to contain %q, got %q" 5 times. -- `cache_test.go:98` — Define a constant instead of duplicating this literal "X-Cache" 6 times. -- `cache_test.go:100` — Define a constant instead of duplicating this literal "expected X-Cache=HIT, got %q" 3 times. -- `cache_test.go:158` — Define a constant instead of duplicating this literal "unmarshal error: %v" 5 times. -- `cache_test.go:179` — Define a constant instead of duplicating this literal "expected counter=2, got %d" 3 times. -- `cache_test.go:207` — Define a constant instead of duplicating this literal "other-2" 4 times. -- `cache_test.go:252` — Define a constant instead of duplicating this literal "X-Request-ID" 8 times. -- `cache_test.go:277` — Define a constant instead of duplicating this literal "first-request-id" 6 times. -- `cache_test.go:288` — Define a constant instead of duplicating this literal "second-request-id" 10 times. -- `chat_completions.go:348` — Define a constant instead of duplicating this literal "models.yaml" 3 times. -- `chat_completions.go:737` — Define a constant instead of duplicating this literal "chat.completion.chunk" 3 times. -- `chat_completions.go:751` — Define a constant instead of duplicating this literal "data: %s\n\n" 3 times. -- `chat_completions_internal_test.go:76` — Define a constant instead of duplicating this literal "unexpected error: %v" 9 times. -- `chat_completions_internal_test.go:203` — Define a constant instead of duplicating this literal "<|channel>thought planning... " 3 times. -- `chat_completions_internal_test.go:214` — Define a constant instead of duplicating this literal " planning... " 3 times. -- `chat_completions_internal_test.go:278` — Define a constant instead of duplicating this literal "Content-Type" 3 times. -- `chat_completions_internal_test.go:297` — Define a constant instead of duplicating this literal "expected %s, got %s" 3 times. -- `chat_completions_internal_test.go:380` — Define a constant instead of duplicating this literal "expected %q, got %q" 3 times. -- `chat_completions_internal_test.go:385` — Define a constant instead of duplicating this literal "hello world" 4 times. -- `chat_completions_test.go:32` — Define a constant instead of duplicating this literal "unexpected error: %v" 3 times. -- `chat_completions_test.go:35` — Define a constant instead of duplicating this literal "/v1/chat/completions" 4 times. -- `chat_completions_test.go:39` — Define a constant instead of duplicating this literal "Content-Type" 4 times. -- `chat_completions_test.go:39` — Define a constant instead of duplicating this literal "application/json" 4 times. -- `client.go:301` — Define a constant instead of duplicating this literal "OpenAPIClient.Call" 4 times. -- `client.go:335` — Define a constant instead of duplicating this literal "application/json" 3 times. -- `client.go:411` — Define a constant instead of duplicating this literal "OpenAPIClient.loadSpec" 4 times. -- `client.go:505` — Define a constant instead of duplicating this literal "OpenAPIClient.buildURL" 3 times. -- `client.go:1026` — Define a constant instead of duplicating this literal "OpenAPIClient.validateOpenAPISchema" 3 times. -- `client.go:1045` — Define a constant instead of duplicating this literal "OpenAPIClient.validateOpenAPIResponse" 3 times. -- `client_test.go:53` — Define a constant instead of duplicating this literal "/hello" 3 times. -- `client_test.go:55` — Define a constant instead of duplicating this literal "expected GET, got %s" 5 times. -- `client_test.go:64` — Define a constant instead of duplicating this literal "Content-Type" 13 times. -- `client_test.go:64` — Define a constant instead of duplicating this literal "application/json" 13 times. -- `client_test.go:113` — Define a constant instead of duplicating this literal "unexpected error: %v" 12 times. -- `client_test.go:123` — Define a constant instead of duplicating this literal "expected map result, got %T" 7 times. -- `client_test.go:336` — Define a constant instead of duplicating this literal "https://api.example.com" 3 times. -- `client_test.go:530` — Define a constant instead of duplicating this literal "expected ok=true, got %#v" 3 times. -- `client_test.go:651` — Define a constant instead of duplicating this literal "expected validation to fail before the HTTP call" 3 times. -- `cmd/api/cmd_args_test.go:18` — Define a constant instead of duplicating this literal "expected %v, got %v" 4 times. -- `cmd/api/cmd_args_test.go:26` — Define a constant instead of duplicating this literal "expected nil, got %v" 3 times. -- `cmd/api/cmd_spec_test.go:145` — Define a constant instead of duplicating this literal "/api/v1/openapi.json" 7 times. -- `cmd/api/cmd_spec_test.go:147` — Define a constant instead of duplicating this literal "/api/v1/chat/completions" 7 times. -- `cmd/api/cmd_spec_test.go:180` — Define a constant instead of duplicating this literal "unexpected error: %v" 4 times. -- `codegen.go:68` — Define a constant instead of duplicating this literal "SDKGenerator.Generate" 11 times. -- `codegen_test.go:34` — Define a constant instead of duplicating this literal "spec.json" 4 times. -- `codegen_test.go:80` — Define a constant instead of duplicating this literal "failed to write spec file: %v" 3 times. -- `export_test.go:24` — Define a constant instead of duplicating this literal "Test API" 8 times. -- `export_test.go:28` — Define a constant instead of duplicating this literal "unexpected error: %v" 7 times. -- `export_test.go:33` — Define a constant instead of duplicating this literal "output is not valid JSON: %v" 3 times. -- `export_test.go:37` — Define a constant instead of duplicating this literal "expected openapi=3.1.0, got %v" 5 times. -- `expvar_test.go:24` — Define a constant instead of duplicating this literal "unexpected error: %v" 4 times. -- `expvar_test.go:30` — Define a constant instead of duplicating this literal "/debug/vars" 5 times. -- `expvar_test.go:32` — Define a constant instead of duplicating this literal "request failed: %v" 4 times. -- `graphql_test.go:58` — Define a constant instead of duplicating this literal "unexpected error: %v" 8 times. -- `graphql_test.go:65` — Define a constant instead of duplicating this literal "/graphql" 5 times. -- `graphql_test.go:65` — Define a constant instead of duplicating this literal "application/json" 7 times. -- `graphql_test.go:67` — Define a constant instead of duplicating this literal "request failed: %v" 7 times. -- `graphql_test.go:72` — Define a constant instead of duplicating this literal "expected 200, got %d" 3 times. -- `graphql_test.go:77` — Define a constant instead of duplicating this literal "failed to read body: %v" 4 times. -- `graphql_test.go:81` — Define a constant instead of duplicating this literal "expected response containing name:test, got %q" 3 times. -- `graphql_test.go:96` — Define a constant instead of duplicating this literal "/graphql/playground" 4 times. -- `graphql_test.go:175` — Define a constant instead of duplicating this literal "playground request failed: %v" 4 times. -- `group_test.go:41` — Define a constant instead of duplicating this literal "expected Name=%q, got %q" 3 times. -- `group_test.go:126` — Define a constant instead of duplicating this literal "List items" 3 times. -- `gzip_test.go:25` — Define a constant instead of duplicating this literal "/stub/ping" 5 times. -- `gzip_test.go:26` — Define a constant instead of duplicating this literal "Accept-Encoding" 4 times. -- `gzip_test.go:30` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times. -- `gzip_test.go:33` — Define a constant instead of duplicating this literal "Content-Encoding" 5 times. -- `httpsign_test.go:53` — Define a constant instead of duplicating this literal "(request-target)" 6 times. -- `httpsign_test.go:97` — Define a constant instead of duplicating this literal "/stub/ping" 5 times. -- `i18n_test.go:66` — Define a constant instead of duplicating this literal "/i18n/locale" 5 times. -- `i18n_test.go:67` — Define a constant instead of duplicating this literal "Accept-Language" 8 times. -- `i18n_test.go:71` — Define a constant instead of duplicating this literal "expected 200, got %d" 9 times. -- `i18n_test.go:76` — Define a constant instead of duplicating this literal "unmarshal error: %v" 9 times. -- `i18n_test.go:79` — Define a constant instead of duplicating this literal "expected locale=%q, got %q" 7 times. -- `i18n_test.go:215` — Define a constant instead of duplicating this literal "/i18n/greeting" 4 times. -- `location_test.go:49` — Define a constant instead of duplicating this literal "/loc/info" 5 times. -- `location_test.go:50` — Define a constant instead of duplicating this literal "X-Forwarded-Host" 3 times. -- `location_test.go:50` — Define a constant instead of duplicating this literal "api.example.com" 3 times. -- `location_test.go:54` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times. -- `location_test.go:59` — Define a constant instead of duplicating this literal "unmarshal error: %v" 5 times. -- `location_test.go:62` — Define a constant instead of duplicating this literal "expected host=%q, got %q" 3 times. -- `location_test.go:132` — Define a constant instead of duplicating this literal "proxy.example.com" 3 times. -- `location_test.go:163` — Define a constant instead of duplicating this literal "secure.example.com" 3 times. -- `middleware_test.go:25` — Define a constant instead of duplicating this literal "/secret" 3 times. -- `middleware_test.go:108` — Define a constant instead of duplicating this literal "/v1/secret" 4 times. -- `middleware_test.go:117` — Define a constant instead of duplicating this literal "unmarshal error: %v" 7 times. -- `middleware_test.go:160` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times. -- `middleware_test.go:178` — Define a constant instead of duplicating this literal "/health" 6 times. -- `middleware_test.go:230` — Define a constant instead of duplicating this literal "X-Request-ID" 9 times. -- `middleware_test.go:247` — Define a constant instead of duplicating this literal "client-id-abc" 3 times. -- `middleware_test.go:266` — Define a constant instead of duplicating this literal "client-id-xyz" 3 times. -- `middleware_test.go:289` — Define a constant instead of duplicating this literal "client-id-meta" 3 times. -- `middleware_test.go:301` — Define a constant instead of duplicating this literal "expected Meta to be present" 4 times. -- `middleware_test.go:304` — Define a constant instead of duplicating this literal "expected request_id=%q, got %q" 4 times. -- `middleware_test.go:307` — Define a constant instead of duplicating this literal "expected duration to be populated" 4 times. -- `middleware_test.go:325` — Define a constant instead of duplicating this literal "client-id-auto-meta" 5 times. -- `middleware_test.go:364` — Define a constant instead of duplicating this literal "client-id-auto-error-meta" 3 times. -- `middleware_test.go:400` — Define a constant instead of duplicating this literal "client-id-plus-json-meta" 3 times. -- `middleware_test.go:436` — Define a constant instead of duplicating this literal "Access-Control-Request-Method" 3 times. -- `middleware_test.go:444` — Define a constant instead of duplicating this literal "Access-Control-Allow-Origin" 3 times. -- `middleware_test.go:462` — Define a constant instead of duplicating this literal "https://app.example.com" 4 times. -- `modernization_test.go:25` — Define a constant instead of duplicating this literal "health-extra" 3 times. -- `modernization_test.go:99` — Define a constant instead of duplicating this literal "https://auth.example.com" 3 times. -- `modernization_test.go:102` — Define a constant instead of duplicating this literal "/public" 6 times. -- `openapi.go:302` — Define a constant instead of duplicating this literal "/health" 4 times. -- `openapi.go:363` — Define a constant instead of duplicating this literal "/debug/pprof" 3 times. -- `openapi.go:371` — Define a constant instead of duplicating this literal "/debug/vars" 3 times. -- `openapi.go:466` — Define a constant instead of duplicating this literal "application/json" 56 times. -- `openapi.go:593` — Define a constant instead of duplicating this literal "Bad request" 3 times. -- `openapi.go:602` — Define a constant instead of duplicating this literal "Too many requests" 7 times. -- `openapi.go:611` — Define a constant instead of duplicating this literal "Gateway timeout" 7 times. -- `openapi.go:620` — Define a constant instead of duplicating this literal "Internal server error" 7 times. -- `openapi_test.go:154` — Define a constant instead of duplicating this literal "unexpected error: %v" 66 times. -- `openapi_test.go:159` — Define a constant instead of duplicating this literal "invalid JSON: %v" 66 times. -- `openapi_test.go:172` — Define a constant instead of duplicating this literal "/health" 7 times. -- `openapi_test.go:173` — Define a constant instead of duplicating this literal "expected /health path in spec" 3 times. -- `openapi_test.go:191` — Define a constant instead of duplicating this literal "X-Request-ID" 6 times. -- `openapi_test.go:194` — Define a constant instead of duplicating this literal "X-RateLimit-Limit" 6 times. -- `openapi_test.go:197` — Define a constant instead of duplicating this literal "X-RateLimit-Remaining" 6 times. -- `openapi_test.go:200` — Define a constant instead of duplicating this literal "X-RateLimit-Reset" 6 times. -- `openapi_test.go:219` — Define a constant instead of duplicating this literal "X-Cache" 3 times. -- `openapi_test.go:444` — Define a constant instead of duplicating this literal "Test API" 4 times. -- `openapi_test.go:456` — Define a constant instead of duplicating this literal "https://example.com/terms" 3 times. -- `openapi_test.go:460` — Define a constant instead of duplicating this literal "API Support" 3 times. -- `openapi_test.go:463` — Define a constant instead of duplicating this literal "https://example.com/support" 3 times. -- `openapi_test.go:466` — Define a constant instead of duplicating this literal "support@example.com" 3 times. -- `openapi_test.go:470` — Define a constant instead of duplicating this literal "EUPL-1.2" 3 times. -- `openapi_test.go:473` — Define a constant instead of duplicating this literal "https://eupl.eu/1.2/en/" 3 times. -- `openapi_test.go:477` — Define a constant instead of duplicating this literal "Developer guide" 3 times. -- `openapi_test.go:480` — Define a constant instead of duplicating this literal "https://example.com/docs" 3 times. -- `openapi_test.go:483` — Define a constant instead of duplicating this literal "x-swagger-ui-path" 3 times. -- `openapi_test.go:587` — Define a constant instead of duplicating this literal "/graphql" 9 times. -- `openapi_test.go:650` — Define a constant instead of duplicating this literal "application/json" 8 times. -- `openapi_test.go:669` — Define a constant instead of duplicating this literal "/graphql/playground" 4 times. -- `openapi_test.go:784` — Define a constant instead of duplicating this literal "x-chat-completions-path" 3 times. -- `openapi_test.go:784` — Define a constant instead of duplicating this literal "/v1/chat/completions" 5 times. -- `openapi_test.go:949` — Define a constant instead of duplicating this literal "/v1/openapi.json" 5 times. -- `openapi_test.go:1053` — Define a constant instead of duplicating this literal "/events" 4 times. -- `openapi_test.go:1357` — Define a constant instead of duplicating this literal "/api/items" 3 times. -- `openapi_test.go:1374` — Define a constant instead of duplicating this literal "Create item" 4 times. -- `openapi_test.go:1471` — Define a constant instead of duplicating this literal "/status" 10 times. -- `openapi_test.go:1907` — Define a constant instead of duplicating this literal "/public" 3 times. -- `openapi_test.go:1908` — Define a constant instead of duplicating this literal "Public endpoint" 3 times. -- `openapi_test.go:1945` — Define a constant instead of duplicating this literal "/api/public" 4 times. -- `openapi_test.go:2218` — Define a constant instead of duplicating this literal "/api/users/{id}" 4 times. -- `openapi_test.go:2244` — Define a constant instead of duplicating this literal "/resources/{id}" 3 times. -- `openapi_test.go:2271` — Define a constant instead of duplicating this literal "/api/resources/{id}" 3 times. -- `openapi_test.go:2338` — Define a constant instead of duplicating this literal "Example resource" 4 times. -- `openapi_test.go:2437` — Define a constant instead of duplicating this literal "Content-Disposition" 3 times. -- `openapi_test.go:2502` — Define a constant instead of duplicating this literal "Get user" 4 times. -- `openapi_test.go:2831` — Define a constant instead of duplicating this literal "Check status" 4 times. -- `openapi_test.go:2852` — Define a constant instead of duplicating this literal "expected tags array, got %T" 5 times. -- `openapi_test.go:3358` — Define a constant instead of duplicating this literal "https://api.example.com" 6 times. -- `pkg/provider/cache_control_test.go:28` — Define a constant instead of duplicating this literal "Cache-Control" 5 times. -- `pkg/provider/proxy_internal_test.go:8` — Define a constant instead of duplicating this literal "/api/v1/cool-widget" 4 times. -- `pkg/provider/proxy_test.go:21` — Define a constant instead of duplicating this literal "cool-widget" 5 times. -- `pkg/provider/proxy_test.go:22` — Define a constant instead of duplicating this literal "/api/v1/cool-widget" 5 times. -- `pkg/provider/proxy_test.go:23` — Define a constant instead of duplicating this literal "http://127.0.0.1:9999" 5 times. -- `pkg/provider/proxy_test.go:69` — Define a constant instead of duplicating this literal "Content-Type" 3 times. -- `pkg/provider/proxy_test.go:69` — Define a constant instead of duplicating this literal "application/json" 3 times. -- `pkg/provider/registry_test.go:25` — Define a constant instead of duplicating this literal "stub.event" 6 times. -- `pkg/provider/registry_test.go:38` — Define a constant instead of duplicating this literal "core-stub-panel" 4 times. -- `pkg/provider/registry_test.go:53` — Define a constant instead of duplicating this literal "/api/full" 3 times. -- `pkg/provider/registry_test.go:60` — Define a constant instead of duplicating this literal "core-full-panel" 3 times. -- `pkg/provider/registry_test.go:316` — Define a constant instead of duplicating this literal "/tmp/a.yaml" 3 times. -- `pkg/stream/stream_group_test.go:22` — Define a constant instead of duplicating this literal "/events" 8 times. -- `pkg/stream/stream_group_test.go:23` — Define a constant instead of duplicating this literal "text/event-stream" 7 times. -- `pkg/stream/stream_group_test.go:152` — Define a constant instead of duplicating this literal "/tenant/socket" 3 times. -- `pprof_test.go:22` — Define a constant instead of duplicating this literal "unexpected error: %v" 4 times. -- `pprof_test.go:28` — Define a constant instead of duplicating this literal "/debug/pprof/" 3 times. -- `pprof_test.go:30` — Define a constant instead of duplicating this literal "request failed: %v" 4 times. -- `ratelimit_internal_test.go:28` — Define a constant instead of duplicating this literal "X-API-Key" 3 times. -- `ratelimit_internal_test.go:30` — Define a constant instead of duplicating this literal "203.0.113.10:1234" 3 times. -- `ratelimit_internal_test.go:79` — Define a constant instead of duplicating this literal "X-RateLimit-Remaining" 3 times. -- `ratelimit_test.go:37` — Define a constant instead of duplicating this literal "/rate/ping" 21 times. -- `ratelimit_test.go:38` — Define a constant instead of duplicating this literal "203.0.113.10:1234" 4 times. -- `ratelimit_test.go:43` — Define a constant instead of duplicating this literal "X-RateLimit-Limit" 3 times. -- `ratelimit_test.go:130` — Define a constant instead of duplicating this literal "203.0.113.20:1234" 3 times. -- `ratelimit_test.go:131` — Define a constant instead of duplicating this literal "X-API-Key" 5 times. -- `ratelimit_test.go:165` — Define a constant instead of duplicating this literal "203.0.113.30:1234" 3 times. -- `ratelimit_test.go:166` — Define a constant instead of duplicating this literal "Bearer token-a" 3 times. -- `ratelimit_test.go:195` — Define a constant instead of duplicating this literal "X-Principal" 3 times. -- `ratelimit_test.go:233` — Define a constant instead of duplicating this literal "X-User-ID" 4 times. -- `ratelimit_test.go:246` — Define a constant instead of duplicating this literal "203.0.113.42:1234" 3 times. -- `response_meta_test.go:91` — Define a constant instead of duplicating this literal "X-Preexisting" 4 times. -- `response_meta_test.go:100` — Define a constant instead of duplicating this literal "application/json" 3 times. -- `response_test.go:32` — Define a constant instead of duplicating this literal "expected Success=true" 3 times. -- `response_test.go:63` — Define a constant instead of duplicating this literal "marshal error: %v" 4 times. -- `response_test.go:68` — Define a constant instead of duplicating this literal "unmarshal error: %v" 7 times. -- `response_test.go:88` — Define a constant instead of duplicating this literal "resource not found" 3 times. -- `response_test.go:226` — Define a constant instead of duplicating this literal "unexpected error: %v" 3 times. -- `response_test.go:236` — Define a constant instead of duplicating this literal "/v1/meta" 3 times. -- `response_test.go:237` — Define a constant instead of duplicating this literal "client-id-meta" 6 times. -- `response_test.go:241` — Define a constant instead of duplicating this literal "expected 200, got %d" 3 times. -- `secure_test.go:24` — Define a constant instead of duplicating this literal "/health" 7 times. -- `secure_test.go:28` — Define a constant instead of duplicating this literal "expected 200, got %d" 4 times. -- `secure_test.go:52` — Define a constant instead of duplicating this literal "X-Frame-Options" 4 times. -- `secure_test.go:83` — Define a constant instead of duplicating this literal "strict-origin-when-cross-origin" 3 times. -- `servers_test.go:11` — Define a constant instead of duplicating this literal "https://api.example.com" 5 times. -- `sessions_test.go:42` — Define a constant instead of duplicating this literal "test-secret-key!" 4 times. -- `sessions_test.go:47` — Define a constant instead of duplicating this literal "/sess/set" 4 times. -- `sessions_test.go:51` — Define a constant instead of duplicating this literal "expected 200, got %d" 4 times. -- `slog_test.go:30` — Define a constant instead of duplicating this literal "/stub/ping" 3 times. -- `slog_test.go:34` — Define a constant instead of duplicating this literal "expected 200, got %d" 4 times. -- `slog_test.go:58` — Define a constant instead of duplicating this literal "/health" 3 times. -- `spec_builder_helper_test.go:22` — Define a constant instead of duplicating this literal "Engine API" 11 times. -- `spec_builder_helper_test.go:22` — Define a constant instead of duplicating this literal "Engine metadata" 11 times. -- `spec_builder_helper_test.go:23` — Define a constant instead of duplicating this literal "Engine overview" 6 times. -- `spec_builder_helper_test.go:25` — Define a constant instead of duplicating this literal "https://example.com/terms" 6 times. -- `spec_builder_helper_test.go:26` — Define a constant instead of duplicating this literal "support@example.com" 3 times. -- `spec_builder_helper_test.go:26` — Define a constant instead of duplicating this literal "API Support" 5 times. -- `spec_builder_helper_test.go:26` — Define a constant instead of duplicating this literal "https://example.com/support" 3 times. -- `spec_builder_helper_test.go:27` — Define a constant instead of duplicating this literal "https://api.example.com" 7 times. -- `spec_builder_helper_test.go:28` — Define a constant instead of duplicating this literal "https://eupl.eu/1.2/en/" 3 times. -- `spec_builder_helper_test.go:28` — Define a constant instead of duplicating this literal "EUPL-1.2" 5 times. -- `spec_builder_helper_test.go:33` — Define a constant instead of duplicating this literal "X-API-Key" 6 times. -- `spec_builder_helper_test.go:36` — Define a constant instead of duplicating this literal "Developer guide" 3 times. -- `spec_builder_helper_test.go:36` — Define a constant instead of duplicating this literal "https://example.com/docs" 5 times. -- `spec_builder_helper_test.go:43` — Define a constant instead of duplicating this literal "https://auth.example.com" 3 times. -- `spec_builder_helper_test.go:44` — Define a constant instead of duplicating this literal "core-client" 3 times. -- `spec_builder_helper_test.go:46` — Define a constant instead of duplicating this literal "/public" 4 times. -- `spec_builder_helper_test.go:48` — Define a constant instead of duplicating this literal "/socket" 7 times. -- `spec_builder_helper_test.go:52` — Define a constant instead of duplicating this literal "/events" 7 times. -- `spec_builder_helper_test.go:57` — Define a constant instead of duplicating this literal "unexpected error: %v" 27 times. -- `spec_builder_helper_test.go:68` — Define a constant instead of duplicating this literal "invalid JSON: %v" 8 times. -- `spec_builder_helper_test.go:88` — Define a constant instead of duplicating this literal "x-swagger-ui-path" 3 times. -- `spec_builder_helper_test.go:567` — Define a constant instead of duplicating this literal "/api/v1/openapi.json" 3 times. -- `spec_registry_test.go:31` — Define a constant instead of duplicating this literal "/alpha" 11 times. -- `sse_test.go:29` — Define a constant instead of duplicating this literal "unexpected error: %v" 12 times. -- `sse_test.go:35` — Define a constant instead of duplicating this literal "/events" 11 times. -- `sse_test.go:37` — Define a constant instead of duplicating this literal "request failed: %v" 11 times. -- `sse_test.go:42` — Define a constant instead of duplicating this literal "expected 200, got %d" 3 times. -- `sse_test.go:45` — Define a constant instead of duplicating this literal "Content-Type" 5 times. -- `sse_test.go:46` — Define a constant instead of duplicating this literal "text/event-stream" 5 times. -- `sse_test.go:47` — Define a constant instead of duplicating this literal "expected Content-Type starting with text/event-stream, got %q" 5 times. -- `sse_test.go:63` — Define a constant instead of duplicating this literal "/v1/events" 3 times. -- `sse_test.go:208` — Define a constant instead of duplicating this literal "event: " 4 times. -- `static_test.go:23` — Define a constant instead of duplicating this literal "hello world" 3 times. -- `static_test.go:24` — Define a constant instead of duplicating this literal "failed to write test file: %v" 4 times. -- `static_test.go:65` — Define a constant instead of duplicating this literal "

Welcome

" 3 times. -- `static_test.go:125` — Define a constant instead of duplicating this literal "sdk-data" 3 times. -- `static_test.go:130` — Define a constant instead of duplicating this literal "body{}" 3 times. -- `sunset_test.go:20` — Define a constant instead of duplicating this literal "/status" 3 times. -- `sunset_test.go:31` — Define a constant instead of duplicating this literal "; rel=\"help\"" 3 times. -- `sunset_test.go:44` — Define a constant instead of duplicating this literal "X-API-Warn" 3 times. -- `sunset_test.go:53` — Define a constant instead of duplicating this literal "/api/v2/status" 4 times. -- `sunset_test.go:53` — Define a constant instead of duplicating this literal "2025-06-01" 3 times. -- `sunset_test.go:55` — Define a constant instead of duplicating this literal "unexpected error: %v" 3 times. -- `sunset_test.go:64` — Define a constant instead of duplicating this literal "expected 200, got %d" 3 times. -- `sunset_test.go:75` — Define a constant instead of duplicating this literal "API-Suggested-Replacement" 8 times. -- `sunset_test.go:94` — Define a constant instead of duplicating this literal "Thu, 30 Apr 2026 23:59:59 GMT" 3 times. -- `sunset_test.go:105` — Define a constant instead of duplicating this literal "POST /api/v2/billing/invoices" 4 times. -- `sunset_test.go:109` — Define a constant instead of duplicating this literal "/billing" 12 times. -- `sunset_test.go:118` — Define a constant instead of duplicating this literal "; rel=\"successor-version\"" 3 times. -- `sunset_test.go:131` — Define a constant instead of duplicating this literal "2026-04-30" 5 times. -- `swagger_test.go:23` — Define a constant instead of duplicating this literal "Test API" 13 times. -- `swagger_test.go:23` — Define a constant instead of duplicating this literal "A test API service" 8 times. -- `swagger_test.go:25` — Define a constant instead of duplicating this literal "unexpected error: %v" 23 times. -- `swagger_test.go:33` — Define a constant instead of duplicating this literal "/swagger/doc.json" 16 times. -- `swagger_test.go:35` — Define a constant instead of duplicating this literal "request failed: %v" 24 times. -- `swagger_test.go:40` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times. -- `swagger_test.go:45` — Define a constant instead of duplicating this literal "failed to read body: %v" 18 times. -- `swagger_test.go:267` — Define a constant instead of duplicating this literal "invalid JSON: %v" 16 times. -- `swagger_test.go:293` — Define a constant instead of duplicating this literal "/api/tools" 3 times. -- `swagger_test.go:296` — Define a constant instead of duplicating this literal "Query metrics data" 3 times. -- `swagger_test.go:535` — Define a constant instead of duplicating this literal "https://eupl.eu/1.2/en/" 5 times. -- `swagger_test.go:535` — Define a constant instead of duplicating this literal "EUPL-1.2" 5 times. -- `swagger_test.go:578` — Define a constant instead of duplicating this literal "support@example.com" 5 times. -- `swagger_test.go:578` — Define a constant instead of duplicating this literal "https://example.com/support" 5 times. -- `swagger_test.go:578` — Define a constant instead of duplicating this literal "API Support" 5 times. -- `swagger_test.go:624` — Define a constant instead of duplicating this literal "https://example.com/terms" 5 times. -- `swagger_test.go:660` — Define a constant instead of duplicating this literal "https://example.com/docs" 5 times. -- `swagger_test.go:660` — Define a constant instead of duplicating this literal "Developer guide" 5 times. -- `swagger_test.go:781` — Define a constant instead of duplicating this literal "https://api.example.com" 6 times. -- `swagger_test.go:950` — Define a constant instead of duplicating this literal "/v1/openapi.json" 5 times. -- `timeout_test.go:50` — Define a constant instead of duplicating this literal "/stub/ping" 3 times. -- `timeout_test.go:59` — Define a constant instead of duplicating this literal "unmarshal error: %v" 4 times. -- `timeout_test.go:65` — Define a constant instead of duplicating this literal "expected Data=%q, got %q" 3 times. -- `tracing_test.go:86` — Define a constant instead of duplicating this literal "/trace" 3 times. -- `tracing_test.go:123` — Define a constant instead of duplicating this literal "test-service" 4 times. -- `tracing_test.go:128` — Define a constant instead of duplicating this literal "/stub/ping" 5 times. -- `tracing_test.go:132` — Define a constant instead of duplicating this literal "expected 200, got %d" 8 times. -- `tracing_test.go:167` — Define a constant instead of duplicating this literal "expected at least one span" 5 times. -- `tracing_test.go:329` — Define a constant instead of duplicating this literal "tracing-test" 3 times. -- `transport_client_test.go:50` — Define a constant instead of duplicating this literal "Bearer secret" 8 times. -- `transport_client_test.go:103` — Define a constant instead of duplicating this literal "ws://example.invalid/ws" 4 times. -- `transport_client_test.go:194` — Define a constant instead of duplicating this literal "http://example.invalid/events" 3 times. -- `transport_client_test.go:204` — Define a constant instead of duplicating this literal "X-Request-ID" 5 times. -- `transport_client_test.go:231` — Define a constant instead of duplicating this literal "Content-Type" 3 times. -- `transport_client_test.go:231` — Define a constant instead of duplicating this literal "text/event-stream" 4 times. -- `webhook_test.go:404` — Define a constant instead of duplicating this literal "https://hooks.example.test/inbox" 4 times. -- `websocket_test.go:34` — Define a constant instead of duplicating this literal "wsstub.updates" 3 times. -- `websocket_test.go:34` — Define a constant instead of duplicating this literal "wsstub.events" 3 times. -- `websocket_test.go:49` — Define a constant instead of duplicating this literal "upgrade error: %v" 6 times. -- `websocket_test.go:58` — Define a constant instead of duplicating this literal "unexpected error: %v" 8 times. -- `websocket_test.go:68` — Define a constant instead of duplicating this literal "failed to dial WebSocket: %v" 3 times. -- `websocket_test.go:74` — Define a constant instead of duplicating this literal "failed to read message: %v" 5 times. -- `websocket_test.go:77` — Define a constant instead of duplicating this literal "expected message=%q, got %q" 6 times. -- `websocket_test.go:263` — Define a constant instead of duplicating this literal "gin-hello" 3 times. - -### php:S1192 — String literals should not be duplicated (58×, code smell) - -- `src/php/src/Api/Boot.php:206` — Define a constant instead of duplicating this literal "/Routes/api.php" 4 times. -- `src/php/src/Api/Boot.php:283` — Define a constant instead of duplicating this literal "/authorize" 3 times. -- `src/php/src/Api/Controllers/Api/WebhookSecretController.php:85` — Define a constant instead of duplicating this literal "Webhook endpoint" 4 times. -- `src/php/src/Api/Controllers/McpApiController.php:109` — Define a constant instead of duplicating this literal "The selected server id is invalid." 7 times. -- `src/php/src/Api/Controllers/McpApiController.php:346` — Define a constant instead of duplicating this literal "The selected tool name is invalid." 5 times. -- `src/php/src/Api/Database/Factories/ApiKeyFactory.php:52` — Define a constant instead of duplicating this literal " API Key" 3 times. -- `src/php/src/Api/Documentation/DocumentationServiceProvider.php:26` — Define a constant instead of duplicating this literal "/config.php" 5 times. -- `src/php/src/Api/Documentation/OpenApiBuilder.php:456` — Define a constant instead of duplicating this literal "Bio Links" 4 times. -- `src/php/src/Api/Models/WebhookEndpoint.php:227` — Define a constant instead of duplicating this literal "The webhook URL must resolve to a public IP address." 3 times. -- `src/php/src/Api/Routes/api.php:134` — Define a constant instead of duplicating this literal "/{workspace}" 4 times. -- `src/php/src/Api/Routes/api.php:161` — Define a constant instead of duplicating this literal "/{id}" 12 times. -- `src/php/src/Api/Services/SeoReportService.php:511` — Define a constant instead of duplicating this literal "The supplied URL could not be resolved to any address." 4 times. -- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:31` — Define a constant instead of duplicating this literal "192.168.1.1" 19 times. -- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:35` — Define a constant instead of duplicating this literal "10.0.0.1" 13 times. -- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:43` — Define a constant instead of duplicating this literal "192.168.1.0/24" 11 times. -- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:67` — Define a constant instead of duplicating this literal "10.0.0.0/8" 4 times. -- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:89` — Define a constant instead of duplicating this literal "2001:db8::1" 9 times. -- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:112` — Define a constant instead of duplicating this literal "2001:db8::/32" 4 times. -- `src/php/src/Api/Tests/Feature/ApiKeyTest.php:386` — Define a constant instead of duplicating this literal "Active Key" 3 times. -- `src/php/src/Api/Tests/Feature/ApiKeyTest.php:719` — Define a constant instead of duplicating this literal "/api/mcp/servers" 3 times. -- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:44` — Define a constant instead of duplicating this literal "Read Only Key" 5 times. -- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:64` — Define a constant instead of duplicating this literal "/api/test-scope/write" 4 times. -- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:81` — Define a constant instead of duplicating this literal "/api/test-scope/delete" 6 times. -- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:100` — Define a constant instead of duplicating this literal "Read/Write Key" 4 times. -- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:243` — Define a constant instead of duplicating this literal "Posts Admin Key" 3 times. -- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:244` — Define a constant instead of duplicating this literal "posts:*" 7 times. -- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:303` — Define a constant instead of duplicating this literal "*:read" 5 times. -- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:524` — Define a constant instead of duplicating this literal "/test-explicit/posts" 3 times. -- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:541` — Define a constant instead of duplicating this literal "/api/test-explicit/posts" 8 times. -- `src/php/src/Api/Tests/Feature/ApiUsageTest.php:37` — Define a constant instead of duplicating this literal "/api/v1/workspaces" 4 times. -- `src/php/src/Api/Tests/Feature/ApiUsageTest.php:83` — Define a constant instead of duplicating this literal "/api/v1/test" 8 times. -- `src/php/src/Api/Tests/Feature/ApiUsageTest.php:192` — Define a constant instead of duplicating this literal "/api/v1/old" 3 times. -- `src/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php:46` — Define a constant instead of duplicating this literal "/api/test-auth/scoped" 4 times. -- `src/php/src/Api/Tests/Feature/DocumentationControllerTest.php:102` — Define a constant instead of duplicating this literal "/api/docs" 3 times. -- `src/php/src/Api/Tests/Feature/McpResourceTest.php:78` — Define a constant instead of duplicating this literal "test-resource-server://documents/welcome" 4 times. -- `src/php/src/Api/Tests/Feature/McpServerAccessTest.php:51` — Define a constant instead of duplicating this literal "/allowed-server.yaml" 6 times. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:108` — Define a constant instead of duplicating this literal "/test-scan/items/{id}" 4 times. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:118` — Define a constant instead of duplicating this literal "api/*" 18 times. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:674` — Define a constant instead of duplicating this literal "Custom Tag" 3 times. -- `src/php/src/Api/Tests/Feature/PixelEndpointTest.php:16` — Define a constant instead of duplicating this literal "/api/pixel/abc12345" 3 times. -- `src/php/src/Api/Tests/Feature/PixelEndpointTest.php:17` — Define a constant instead of duplicating this literal "https://example.com" 6 times. -- `src/php/src/Api/Tests/Feature/PublicApiCorsTest.php:48` — Define a constant instead of duplicating this literal "https://example.com" 5 times. -- `src/php/src/Api/Tests/Feature/RateLimitingTest.php:706` — Define a constant instead of duplicating this literal "127.0.0.1" 3 times. -- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:45` — Define a constant instead of duplicating this literal "https://1.1.1.1/article" 5 times. -- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:75` — Define a constant instead of duplicating this literal "text/html; charset=utf-8" 4 times. -- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:87` — Define a constant instead of duplicating this literal "Example Product Landing Page" 3 times. -- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:88` — Define a constant instead of duplicating this literal "A concise example description for the landing page." 3 times. -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:69` — Define a constant instead of duplicating this literal "{"event":"test"}" 13 times. -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:331` — Define a constant instead of duplicating this literal "https://1.1.1.1/webhook" 16 times. -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:460` — Define a constant instead of duplicating this literal "https://example.com/webhook" 13 times. -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:535` — Define a constant instead of duplicating this literal "Server Error" 3 times. -- `src/php/src/Website/Api/Services/OpenApiGenerator.php:88` — Define a constant instead of duplicating this literal "Chat Widget" 3 times. -- `src/php/tests/Feature/ApiSunsetTest.php:14` — Define a constant instead of duplicating this literal "/legacy-endpoint" 10 times. -- `src/php/tests/Feature/ApiSunsetTest.php:44` — Define a constant instead of duplicating this literal "2025-06-01" 7 times. -- `src/php/tests/Feature/ApiSunsetTest.php:46` — Define a constant instead of duplicating this literal "; rel="successor-version"" 4 times. -- `src/php/tests/Feature/ApiSunsetTest.php:57` — Define a constant instead of duplicating this literal "/api/v2/users" 9 times. -- `src/php/tests/Feature/ApiVersionServiceTest.php:47` — Define a constant instead of duplicating this literal "/api/users" 6 times. -- `src/php/tests/Feature/AuthenticationGuideTest.php:21` — Define a constant instead of duplicating this literal "API keys are prefixed with" 3 times. - -### go:S3776 — Cognitive Complexity of functions should not be too high (39×, code smell) - -- `api.go:253` — Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed. -- `authentik.go:171` — Refactor this method to reduce its Cognitive Complexity from 38 to the 15 allowed. -- `authentik_integration_test.go:89` — Refactor this method to reduce its Cognitive Complexity from 23 to the 15 allowed. -- `bridge.go:451` — Refactor this method to reduce its Cognitive Complexity from 97 to the 15 allowed. -- `bridge.go:566` — Refactor this method to reduce its Cognitive Complexity from 27 to the 15 allowed. -- `cache.go:90` — Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed. -- `cache.go:191` — Refactor this method to reduce its Cognitive Complexity from 41 to the 15 allowed. -- `chat_completions.go:375` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `chat_completions.go:716` — Refactor this method to reduce its Cognitive Complexity from 33 to the 15 allowed. -- `client.go:181` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed. -- `client.go:291` — Refactor this method to reduce its Cognitive Complexity from 37 to the 15 allowed. -- `client.go:398` — Refactor this method to reduce its Cognitive Complexity from 37 to the 15 allowed. -- `client.go:502` — Refactor this method to reduce its Cognitive Complexity from 28 to the 15 allowed. -- `client.go:570` — Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed. -- `client.go:775` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `client_test.go:749` — Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed. -- `cmd/api/cmd_sdk.go:31` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed. -- `i18n.go:159` — Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed. -- `openapi.go:85` — Refactor this method to reduce its Cognitive Complexity from 49 to the 15 allowed. -- `openapi.go:297` — Refactor this method to reduce its Cognitive Complexity from 88 to the 15 allowed. -- `openapi.go:943` — Refactor this method to reduce its Cognitive Complexity from 23 to the 15 allowed. -- `openapi.go:1983` — Refactor this method to reduce its Cognitive Complexity from 32 to the 15 allowed. -- `openapi.go:2214` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed. -- `openapi.go:2750` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed. -- `openapi_test.go:145` — Refactor this method to reduce its Cognitive Complexity from 28 to the 15 allowed. -- `openapi_test.go:582` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `openapi_test.go:1722` — Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed. -- `openapi_test.go:2075` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed. -- `openapi_test.go:2914` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `pkg/provider/registry.go:213` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `pkg/stream/stream_group_test.go:168` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed. -- `ratelimit.go:63` — Refactor this method to reduce its Cognitive Complexity from 37 to the 15 allowed. -- `runtime_config_test.go:15` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `spec_builder_helper.go:238` — Refactor this method to reduce its Cognitive Complexity from 26 to the 15 allowed. -- `spec_builder_helper_test.go:17` — Refactor this method to reduce its Cognitive Complexity from 57 to the 15 allowed. -- `spec_builder_helper_test.go:247` — Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed. -- `spec_builder_helper_test.go:347` — Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed. -- `sse.go:149` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `transport_client.go:264` — Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed. - -### go:S1186 — Functions should not be empty (31×, code smell) - -- `api_describable_test.go:23` — Add a nested comment explaining why this function is empty or complete the implementation. -- `api_renderable_test.go:23` — Add a nested comment explaining why this function is empty or complete the implementation. -- `bridge.go:804` — Add a nested comment explaining why this function is empty or complete the implementation. -- `bridge_test.go:132` — Add a nested comment explaining why this function is empty or complete the implementation. -- `bridge_test.go:198` — Add a nested comment explaining why this function is empty or complete the implementation. -- `bridge_test.go:266` — Add a nested comment explaining why this function is empty or complete the implementation. -- `bridge_test.go:277` — Add a nested comment explaining why this function is empty or complete the implementation. -- `bridge_test.go:334` — Add a nested comment explaining why this function is empty or complete the implementation. -- `bridge_test.go:967` — Add a nested comment explaining why this function is empty or complete the implementation. -- `bridge_test.go:968` — Add a nested comment explaining why this function is empty or complete the implementation. -- `bridge_test.go:969` — Add a nested comment explaining why this function is empty or complete the implementation. -- `bridge_test.go:1026` — Add a nested comment explaining why this function is empty or complete the implementation. -- `bridge_test.go:1031` — Add a nested comment explaining why this function is empty or complete the implementation. -- `cache_control_test.go:19` — Add a nested comment explaining why this function is empty or complete the implementation. -- `cmd/api/cmd_sdk_test.go:166` — Add a nested comment explaining why this function is empty or complete the implementation. -- `cmd/api/cmd_spec_test.go:21` — Add a nested comment explaining why this function is empty or complete the implementation. -- `cmd/api/spec_groups_iter.go:51` — Add a nested comment explaining why this function is empty or complete the implementation. -- `openapi_test.go:28` — Add a nested comment explaining why this function is empty or complete the implementation. -- `openapi_test.go:36` — Add a nested comment explaining why this function is empty or complete the implementation. -- `openapi_test.go:46` — Add a nested comment explaining why this function is empty or complete the implementation. -- `openapi_test.go:66` — Add a nested comment explaining why this function is empty or complete the implementation. -- `openapi_test.go:81` — Add a nested comment explaining why this function is empty or complete the implementation. -- `openapi_test.go:102` — Add a nested comment explaining why this function is empty or complete the implementation. -- `openapi_test.go:140` — Add a nested comment explaining why this function is empty or complete the implementation. -- `pkg/provider/registry_test.go:21` — Add a nested comment explaining why this function is empty or complete the implementation. -- `pkg/stream/stream_group_test.go:83` — Add a nested comment explaining why this function is empty or complete the implementation. -- `spec_builder_helper_test.go:49` — Add a nested comment explaining why this function is empty or complete the implementation. -- `spec_builder_helper_test.go:436` — Add a nested comment explaining why this function is empty or complete the implementation. -- `spec_registry_test.go:21` — Add a nested comment explaining why this function is empty or complete the implementation. -- `swagger_internal_test.go:20` — Add a nested comment explaining why this function is empty or complete the implementation. -- `tracing_test.go:111` — Add a nested comment explaining why this function is empty or complete the implementation. - -### php:S1186 — Methods should not be empty (17×, code smell) - -- `src/php/src/Api/Tests/Feature/McpApiControllerTest.php:167` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/McpApiControllerTest.php:176` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:170` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:180` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:189` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1197` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1202` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1206` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1211` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1215` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1226` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1234` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1242` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1244` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1253` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1264` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. -- `src/php/src/Api/Tests/Feature/RateLimitTest.php:257` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation. - -### php:S3776 — Cognitive Complexity of functions should not be too high (17×, code smell) - -- `src/php/src/Api/Console/Commands/CheckApiUsageAlerts.php:125` — Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:411` — Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:598` — Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:754` — Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:944` — Refactor this function to reduce its Cognitive Complexity from 89 to the 15 allowed. -- `src/php/src/Api/Documentation/Extensions/SunsetExtension.php:76` — Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `src/php/src/Api/Documentation/Extensions/SunsetExtension.php:139` — Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed. -- `src/php/src/Api/Documentation/Middleware/ProtectDocumentation.php:22` — Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed. -- `src/php/src/Api/Middleware/AuthenticateApiKey.php:120` — Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed. -- `src/php/src/Api/Models/ApiKey.php:162` — Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed. -- `src/php/src/Api/Models/WebhookEndpoint.php:178` — Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed. -- `src/php/src/Api/Services/SeoReportService.php:456` — Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `src/php/src/Api/Services/SeoReportService.php:536` — Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed. -- `src/php/src/Api/Services/WebhookSecretRotationService.php:274` — Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed. -- `src/php/src/Api/Tests/Feature/RateLimitingTest.php:459` — Refactor this function to reduce its Cognitive Complexity from 24 to the 15 allowed. -- `src/php/src/Front/Api/Middleware/ApiVersion.php:75` — Refactor this function to reduce its Cognitive Complexity from 22 to the 15 allowed. -- `src/php/src/Front/Api/VersionedRoutes.php:252` — Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed. - -## MAJOR - -### php:S1142 — Functions should not contain too many return statements (62×, code smell) - -- `src/php/src/Api/Concerns/ResolvesWorkspace.php:27` — This method has 6 returns, which is more than the 3 allowed. -- `src/php/src/Api/Console/Commands/CheckApiUsageAlerts.php:259` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/Api/ApiKeyController.php:59` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/Api/PaymentMethodController.php:84` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/Api/WebhookTemplateController.php:133` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/Api/WebhookTemplateController.php:190` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/Api/WorkspaceMemberController.php:92` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:105` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:154` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:221` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:373` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:411` — This method has 8 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:666` — This method has 6 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:711` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:754` — This method has 9 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:1308` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:1362` — This method has 6 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:1429` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:1498` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Controllers/McpApiController.php:1520` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Documentation/Extensions/RateLimitExtension.php:152` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Documentation/Extensions/RateLimitExtension.php:188` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Documentation/Extensions/RateLimitExtension.php:234` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Documentation/Extensions/VersionExtension.php:98` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Documentation/Middleware/ProtectDocumentation.php:22` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Documentation/OpenApiBuilder.php:356` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Documentation/OpenApiBuilder.php:492` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Documentation/OpenApiBuilder.php:749` — This method has 8 returns, which is more than the 3 allowed. -- `src/php/src/Api/Documentation/OpenApiBuilder.php:959` — This method has 6 returns, which is more than the 3 allowed. -- `src/php/src/Api/Documentation/OpenApiBuilder.php:1103` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Middleware/ApiCacheControl.php:23` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Middleware/AuthenticateApiKey.php:31` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Middleware/AuthenticateApiKey.php:77` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Middleware/AuthenticateApiKey.php:120` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Middleware/AuthenticateApiKey.php:181` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Middleware/RateLimitApi.php:82` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Middleware/RateLimitApi.php:134` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Middleware/RateLimitApi.php:192` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Middleware/RateLimitApi.php:313` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Middleware/RateLimitApi.php:345` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Models/ApiKey.php:162` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Models/ApiKey.php:331` — This method has 6 returns, which is more than the 3 allowed. -- `src/php/src/Api/Models/WebhookDelivery.php:203` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Models/WebhookEndpoint.php:296` — This method has 7 returns, which is more than the 3 allowed. -- `src/php/src/Api/Models/WebhookEndpoint.php:424` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/RateLimit/RateLimitService.php:208` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/RateLimit/RateLimitService.php:264` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/RateLimit/RateLimitService.php:342` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Services/IpRestrictionService.php:26` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Services/IpRestrictionService.php:66` — This method has 6 returns, which is more than the 3 allowed. -- `src/php/src/Api/Services/IpRestrictionService.php:128` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Services/IpRestrictionService.php:208` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Services/IpRestrictionService.php:234` — This method has 6 returns, which is more than the 3 allowed. -- `src/php/src/Api/Services/SeoReportService.php:591` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Services/WebhookSecretRotationService.php:85` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Services/WebhookSecretRotationService.php:274` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Api/Services/WebhookTemplateService.php:214` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Services/WebhookTemplateService.php:547` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Services/WebhookTemplateService.php:568` — This method has 5 returns, which is more than the 3 allowed. -- `src/php/src/Api/Tests/Feature/WebhookEndpointTest.php:6` — This function has 5 returns, which is more than the 3 allowed. -- `src/php/src/Front/Api/Middleware/ApiSunset.php:105` — This method has 4 returns, which is more than the 3 allowed. -- `src/php/src/Website/Api/Services/OpenApiGenerator.php:308` — This method has 4 returns, which is more than the 3 allowed. - -### php:S112 — Generic exceptions ErrorException, RuntimeException and Exception should not be thrown (33×, code smell) - -- `src/php/src/Api/Controllers/McpApiController.php:854` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:863` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:867` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:903` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:914` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:931` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:935` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:960` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:970` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:980` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:1018` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:1028` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:1052` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:1069` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:1096` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Controllers/McpApiController.php:1102` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Models/WebhookDelivery.php:87` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Models/WebhookDelivery.php:184` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Services/ApiKeyService.php:51` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Services/ApiKeyService.php:90` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Services/ApiKeyService.php:97` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Services/ApiKeyService.php:133` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Services/SeoReportService.php:43` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Services/SeoReportService.php:50` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Services/SeoReportService.php:134` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Services/SeoReportService.php:141` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Services/SeoReportService.php:148` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Services/SeoReportService.php:154` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Tests/Feature/ApiKeyTest.php:279` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php:106` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Tests/Feature/McpApiControllerTest.php:164` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Tests/Feature/RateLimitTest.php:281` — Define and throw a dedicated exception instead of using a generic one. -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:443` — Define and throw a dedicated exception instead of using a generic one. - -### php:S1172 — Unused function parameters should be removed (29×, code smell) - -- `src/php/src/Api/Database/Factories/ApiKeyFactory.php:139` — Remove the unused function parameter "$attributes". -- `src/php/src/Api/Documentation/DocumentationController.php:45` — Remove the unused function parameter "$request". -- `src/php/src/Api/Documentation/DocumentationController.php:58` — Remove the unused function parameter "$request". -- `src/php/src/Api/Documentation/DocumentationController.php:71` — Remove the unused function parameter "$request". -- `src/php/src/Api/Documentation/DocumentationController.php:81` — Remove the unused function parameter "$request". -- `src/php/src/Api/Documentation/DocumentationController.php:94` — Remove the unused function parameter "$request". -- `src/php/src/Api/Documentation/DocumentationController.php:105` — Remove the unused function parameter "$request". -- `src/php/src/Api/Documentation/DocumentationController.php:120` — Remove the unused function parameter "$request". -- `src/php/src/Api/Documentation/DocumentationServiceProvider.php:40` — Remove the unused function parameter "$app". -- `src/php/src/Api/Documentation/Examples/CommonExamples.php:121` — Remove the unused function parameter "$status". -- `src/php/src/Api/Documentation/OpenApiBuilder.php:525` — Remove the unused function parameter "$config". -- `src/php/src/Api/Documentation/OpenApiBuilder.php:836` — Remove the unused function parameter "$value". -- `src/php/src/Api/Documentation/OpenApiBuilder.php:907` — Remove the unused function parameter "$route". -- `src/php/src/Api/Services/WebhookTemplateService.php:547` — Remove the unused function parameter "$arg". -- `src/php/src/Api/Services/WebhookTemplateService.php:568` — Remove the unused function parameter "$arg". -- `src/php/src/Api/Services/WebhookTemplateService.php:596` — Remove the unused function parameter "$arg". -- `src/php/src/Api/Services/WebhookTemplateService.php:601` — Remove the unused function parameter "$arg". -- `src/php/src/Api/Services/WebhookTemplateService.php:606` — Remove the unused function parameter "$arg". -- `src/php/src/Api/Services/WebhookTemplateService.php:632` — Remove the unused function parameter "$arg". -- `src/php/src/Api/Services/WebhookTemplateService.php:637` — Remove the unused function parameter "$arg". -- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:72` — Remove the unused function parameter "$serverId". -- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:72` — Remove the unused function parameter "$toolName". -- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:143` — Remove the unused function parameter "$server". -- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:143` — Remove the unused function parameter "$version". -- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:143` — Remove the unused function parameter "$tool". -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1204` — Remove the unused function parameter "$id". -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1213` — Remove the unused function parameter "$id". -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1217` — Remove the unused function parameter "$id". -- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1255` — Remove the unused function parameter "$id". - -### Web:S5255 — "aria-label" or "aria-labelledby" attributes should be used to differentiate similar elements (12×, code smell) - -- `src/php/src/Website/Api/View/Blade/guides/authentication.blade.php:12` — Add an "aria-label" or "aria-labbelledby" attribute to this element. -- `src/php/src/Website/Api/View/Blade/guides/authentication.blade.php:49` — Add an "aria-label" or "aria-labbelledby" attribute to this element. -- `src/php/src/Website/Api/View/Blade/guides/errors.blade.php:11` — Add an "aria-label" or "aria-labbelledby" attribute to this element. -- `src/php/src/Website/Api/View/Blade/guides/errors.blade.php:48` — Add an "aria-label" or "aria-labbelledby" attribute to this element. -- `src/php/src/Website/Api/View/Blade/guides/qrcodes.blade.php:11` — Add an "aria-label" or "aria-labbelledby" attribute to this element. -- `src/php/src/Website/Api/View/Blade/guides/qrcodes.blade.php:48` — Add an "aria-label" or "aria-labbelledby" attribute to this element. -- `src/php/src/Website/Api/View/Blade/guides/quickstart.blade.php:12` — Add an "aria-label" or "aria-labbelledby" attribute to this element. -- `src/php/src/Website/Api/View/Blade/guides/quickstart.blade.php:49` — Add an "aria-label" or "aria-labbelledby" attribute to this element. -- `src/php/src/Website/Api/View/Blade/guides/rate-limits.blade.php:10` — Add an "aria-label" or "aria-labbelledby" attribute to this element. -- `src/php/src/Website/Api/View/Blade/guides/rate-limits.blade.php:23` — Add an "aria-label" or "aria-labbelledby" attribute to this element. -- `src/php/src/Website/Api/View/Blade/guides/webhooks.blade.php:11` — Add an "aria-label" or "aria-labbelledby" attribute to this element. -- `src/php/src/Website/Api/View/Blade/guides/webhooks.blade.php:63` — Add an "aria-label" or "aria-labbelledby" attribute to this element. - -### php:S1448 — Classes should not have too many methods (8×, code smell) - -- `src/php/src/Api/Controllers/McpApiController.php:27` — Class "McpApiController" has 37 methods, which is greater than 20 authorized. Split it into smaller classes. -- `src/php/src/Api/Documentation/OpenApiBuilder.php:31` — Class "OpenApiBuilder" has 38 methods, which is greater than 20 authorized. Split it into smaller classes. -- `src/php/src/Api/Models/ApiKey.php:26` — Class "ApiKey" has 35 methods, which is greater than 20 authorized. Split it into smaller classes. -- `src/php/src/Api/Models/WebhookEndpoint.php:32` — Class "WebhookEndpoint" has 25 methods, which is greater than 20 authorized. Split it into smaller classes. -- `src/php/src/Api/Models/WebhookPayloadTemplate.php:41` — Class "WebhookPayloadTemplate" has 24 methods, which is greater than 20 authorized. Split it into smaller classes. -- `src/php/src/Api/Services/ApiSnippetService.php:12` — Class "ApiSnippetService" has 21 methods, which is greater than 20 authorized. Split it into smaller classes. -- `src/php/src/Api/Services/WebhookTemplateService.php:22` — Class "WebhookTemplateService" has 28 methods, which is greater than 20 authorized. Split it into smaller classes. -- `src/php/src/Api/View/Modal/Admin/WebhookTemplateManager.php:20` — Class "WebhookTemplateManager" has 27 methods, which is greater than 20 authorized. Split it into smaller classes. - -### php:S3358 — Ternary operators should not be nested (2×, code smell) - -- `src/php/src/Api/Models/WebhookEndpoint.php:198` — Extract this nested ternary operation into an independent statement. -- `src/php/src/Api/Services/SeoReportService.php:473` — Extract this nested ternary operation into an independent statement. - -### php:S138 — Functions should not have too many lines of code (2×, code smell) - -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:51` — This function expression has 158 lines, which is greater than the 150 lines authorized. Split it into smaller functions. -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:550` — This function expression has 215 lines, which is greater than the 150 lines authorized. Split it into smaller functions. - -### Web:S6853 — Label elements should have a text label and an associated control (2×, code smell) - -- `src/php/src/Api/View/Blade/admin/webhook-template-manager.blade.php:264` — A form label must be associated with a control and have accessible text. -- `src/php/src/Api/View/Blade/admin/webhook-template-manager.blade.php:268` — A form label must be associated with a control and have accessible text. - -### php:S3011 — Reflection should not be used to increase accessibility of classes, methods, or fields (2×, code smell) - -- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:40` — Make sure that this accessibility bypass is safe here. -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:36` — Make sure that this accessibility bypass is safe here. - -### php:S107 — Functions should not have too many parameters (1×, code smell) - -- `src/php/src/Api/Services/ApiUsageService.php:22` — This function has 10 parameters, which is greater than the 7 authorized. - -### php:S1066 — Mergeable "if" statements should be combined (1×, code smell) - -- `src/php/src/Api/Services/SeoReportService.php:297` — Merge this if statement with the enclosing one. - -### go:S107 — Functions should not have too many parameters (1×, code smell) - -- `openapi.go:554` — This function has 12 parameters, which is greater than the 7 authorized. - -### php:S1068 — Unused "private" fields should be removed (1×, code smell) - -- `src/php/src/Api/Services/WebhookSignature.php:55` — Remove this unused "SECRET_LENGTH" private field. - -## MINOR - -### php:S1481 — Unused local variables should be removed (7×, code smell) - -- `src/php/src/Api/Documentation/OpenApiBuilder.php:452` — Remove this unused "$name" local variable. -- `src/php/src/Api/Tests/Feature/ApiKeyRotationTest.php:218` — Remove this unused "$key1" local variable. -- `src/php/src/Api/Tests/Feature/ApiUsageTest.php:189` — Remove this unused "$usage" local variable. -- `src/php/src/Api/Tests/Feature/RateLimitTest.php:606` — Remove this unused "$tier" local variable. -- `src/php/src/Api/Tests/Feature/RateLimitingTest.php:360` — Remove this unused "$apiKey2" local variable. -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:433` — Remove this unused "$endpoint" local variable. -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:474` — Remove this unused "$endpoint" local variable. - -### php:S100 — Function names should comply with a naming convention (3×, code smell) - -- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:6` — Rename function "dns_get_record" to match the regular expression ^[a-z][a-zA-Z0-9]*$. -- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:6` — Rename function "dns_get_record" to match the regular expression ^[a-z][a-zA-Z0-9]*$. -- `src/php/src/Api/Tests/Feature/WebhookEndpointTest.php:6` — Rename function "dns_get_record" to match the regular expression ^[a-z][a-zA-Z0-9]*$. - -### go:S1940 — Boolean checks should not be inverted (2×, code smell) - -- `client.go:687` — Use the opposite operator ("!=") instead. -- `client.go:729` — Use the opposite operator ("!=") instead. - -### php:S6353 — Regular expression quantifiers and character classes should be used concisely (2×, code smell) - -- `src/php/src/Api/Services/WebhookTemplateService.php:343` — Use concise character class syntax '\w' instead of '[a-zA-Z0-9_]'. -- `src/php/src/Api/Services/WebhookTemplateService.php:486` — Use concise character class syntax '\w' instead of '[a-zA-Z0-9_]'. - -### php:S1488 — Local variables should not be declared and then immediately returned or thrown (1×, code smell) - -- `src/php/src/Api/Services/WebhookTemplateService.php:139` — Immediately return this expression instead of assigning it to the temporary variable "$variables". - From 6d87ed53e2ebb4e91f6af8db0b3c99a076b81a4e Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 27 Jun 2026 13:54:26 +0100 Subject: [PATCH 5/5] chore: drop shipped superpowers design docs + gitignore docs/superpowers/ Verified each design's work is present in source before removal; the docs are redundant replication guides. Future superpowers output stays local (gitignored). Co-Authored-By: Virgil --- .gitignore | 3 + ...6-06-06-chat-completions-remote-backend.md | 1482 --------------- ...-06-06-openapi-inference-describability.md | 396 ---- .../plans/2026-06-06-upstream-router.md | 1601 ----------------- ...-chat-completions-remote-backend-design.md | 257 --- ...openapi-inference-describability-design.md | 105 -- .../2026-06-06-upstream-router-design.md | 309 ---- 7 files changed, 3 insertions(+), 4150 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-06-chat-completions-remote-backend.md delete mode 100644 docs/superpowers/plans/2026-06-06-openapi-inference-describability.md delete mode 100644 docs/superpowers/plans/2026-06-06-upstream-router.md delete mode 100644 docs/superpowers/specs/2026-06-06-chat-completions-remote-backend-design.md delete mode 100644 docs/superpowers/specs/2026-06-06-openapi-inference-describability-design.md delete mode 100644 docs/superpowers/specs/2026-06-06-upstream-router-design.md diff --git a/.gitignore b/.gitignore index caeb728..1af8c71 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ node_modules.bak/ coverage/ htmlcov/ .coverage + +# superpowers design/plan scratch — not committed (shipped work lives in code) +docs/superpowers/ diff --git a/docs/superpowers/plans/2026-06-06-chat-completions-remote-backend.md b/docs/superpowers/plans/2026-06-06-chat-completions-remote-backend.md deleted file mode 100644 index 7c1c540..0000000 --- a/docs/superpowers/plans/2026-06-06-chat-completions-remote-backend.md +++ /dev/null @@ -1,1482 +0,0 @@ -# Chat Completions — Remote Backend + Format Adapters Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `/v1/chat/completions` serve remote models (OpenAI-compatible passthrough + per-model Ollama/Anthropic adapters) alongside the existing local in-process path, choosing local-first by model name. - -**Architecture:** The chat handler gains an optional remote backend. Local-first: if `ModelResolver.Knows(model)` → existing in-process path; else → remote via the upstream router's `upstreamBalancer`+`upstreamTransport` (weighted RR + failover, reused unchanged). Passthrough is default (verbatim bytes both ways); a per-model `ChatFormatAdapter` maps non-OpenAI formats (Ollama-native, Anthropic) including per-chunk streaming transcoders. Off-loopback access is an opt-in gated by a configured bearer. - -**Tech Stack:** Go 1.26, gin, `dappco.re/go` (core), `dappco.re/go/inference`, the existing `chat_completions.go` + `upstream_*.go` (router). Spec: `docs/superpowers/specs/2026-06-06-chat-completions-remote-backend-design.md`. - -**Conventions:** SPDX header on every file. UK English in strings. `_Good/_Bad/_Ugly` test suffixes. Run from `core/api/go` with `GOWORK=off go test ./ ...`. Commit `Co-Authored-By: Virgil `. - -**Reused symbols (already in package `api`, do NOT redefine):** `UpstreamRegistry`/`NewUpstreamRegistry`/`AllowPrivateUpstreams`/`Upstream`/`.resolve`, `upstreamBalancer`/`newUpstreamBalancer`, `upstreamTransport`, `routerError`, `poolCtxKey`/`keyCtxKey`, `defaultFailoverStatuses`, `defaultUpstreamCooldown`, `maxUpstreamResponseBytes`, `maxToolRequestBodyBytes`. Chat: `ChatCompletionRequest/Response/Chunk/ChatMessage/ChatChoice/ChatUsage/ChatChunkChoice/ChatMessageDelta`, `isLoopbackRequest`, `writeChatCompletionError(c,status,errType,param,message,code)`, `mapResolverError`, `newChatCompletionID`, `decodeJSONBody`, `validateChatRequest`, `defaultChatCompletionsPath`, `chatDefaultMaxTokens`. Engine: `e.bearerConfigured`, `e.chatCompletionsResolver`, `e.chatCompletionsPath`. - ---- - -## File Structure - -| File | Responsibility | -|------|----------------| -| `go/chat_completions.go` (modify) | Add `ModelResolver.Knows`; extend `chatCompletionsHandler` (resolver?+remote?+allowRemote+bearerConfigured), bind guard, local-first dispatch | -| `go/chat_remote.go` (create) | `chatRemoteConfig`, `dispatchRemote`, response delivery (passthrough/adapter), `*routerError`→OpenAI mapping | -| `go/chat_adapter.go` (create) | `ChatFormatAdapter`, `ChatStreamTranscoder`, `ChatStreamMeta` interfaces + small shared SSE helpers | -| `go/chat_adapter_ollama.go` (create) | `OllamaAdapter` | -| `go/chat_adapter_anthropic.go` (create) | `AnthropicAdapter` | -| `go/options.go` (modify) | `WithChatCompletionsRemote`, `WithChatModelAdapter`, `WithChatRemoteFailover`, `WithChatRemoteTransport`, `WithChatCompletionsAllowRemoteClients` | -| `go/api.go` (modify) | Engine fields `chatRemote *chatRemoteConfig`, `chatAllowRemote bool`; pass into handler in `build()` | - ---- - -## Task 1: `ModelResolver.Knows()` — cheap local existence check - -**Files:** -- Modify: `go/chat_completions.go` -- Test: `go/chat_remote_internal_test.go` (create; `package api`) - -- [ ] **Step 1: Write the failing test** - -Create `go/chat_remote_internal_test.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import "testing" - -func TestModelResolver_Knows_Good(t *testing.T) { - r := NewModelResolver() - // Seed the loaded-by-name cache directly (internal test) to simulate a known model. - r.loadedByName["lemer"] = nil - if !r.Knows("lemer") { - t.Fatal("Knows(lemer) = false, want true (cache hit)") - } -} - -func TestModelResolver_Knows_Bad(t *testing.T) { - r := NewModelResolver() - if r.Knows("does-not-exist") { - t.Fatal("Knows(does-not-exist) = true, want false") - } - if r.Knows("") { - t.Fatal("Knows(empty) = true, want false") - } - var nilR *ModelResolver - if nilR.Knows("x") { - t.Fatal("nil resolver Knows = true, want false") - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestModelResolver_Knows` -Expected: FAIL — `r.Knows undefined`. - -- [ ] **Step 3: Implement `Knows`** - -In `go/chat_completions.go`, add after `ResolveModel` (around line 300): - -```go -// Knows reports whether the resolver can serve name WITHOUT loading it — a hit -// in the loaded-model cache, the models.yaml mapping, or the discovery set. It -// mirrors ResolveModel's three resolution sources so a false result means -// ResolveModel could not have served the model either. Used by the chat handler -// to route local-vs-remote without triggering a model load (see chat_remote.go). -func (r *ModelResolver) Knows(name string) bool { - if r == nil || core.Trim(name) == "" { - return false - } - r.mu.RLock() - _, cached := r.loadedByName[name] - r.mu.RUnlock() - if cached { - return true - } - if _, ok := r.lookupModelPath(name); ok { - return true - } - if _, ok := r.resolveDiscoveredPath(name); ok { - return true - } - return false -} -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestModelResolver_Knows -race` -Expected: PASS (both tests). - -- [ ] **Step 5: Commit** - -```bash -cd /Users/snider/Code/core/api -git add go/chat_completions.go go/chat_remote_internal_test.go -git commit -m "$(printf 'feat(api): ModelResolver.Knows — no-load local existence check\n\nCo-Authored-By: Virgil ')" -``` - ---- - -## Task 2: Remote backend core — config, options, dispatch (passthrough), wiring - -**Files:** -- Create: `go/chat_adapter.go`, `go/chat_remote.go` -- Modify: `go/options.go`, `go/api.go`, `go/chat_completions.go` -- Test: `go/chat_remote_test.go` (create; `package api_test`) - -- [ ] **Step 1: Write the adapter interfaces (`chat_adapter.go`)** - -Create `go/chat_adapter.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import "io" // Note: AX-6 — io.Writer/Reader are the transcoder stream boundary. - -// ChatFormatAdapter maps between the OpenAI chat shape and a non-OpenAI upstream. -// OpenAI-compatible upstreams need NO adapter — passthrough is the default. -type ChatFormatAdapter interface { - // Name identifies the adapter, e.g. "ollama", "anthropic". - Name() string - // UpstreamPath is the path under the upstream base URL, e.g. "/api/chat". - UpstreamPath() string - // BuildRequest maps the OpenAI request into the upstream body + protocol - // headers (Content-Type, anthropic-version). Operator secrets (x-api-key) - // belong in Upstream.Headers, not here. - BuildRequest(req ChatCompletionRequest) (body []byte, headers map[string]string, err error) - // DecodeResponse maps a complete (non-streaming) upstream body into the - // OpenAI response. - DecodeResponse(model string, upstream []byte) (ChatCompletionResponse, error) - // Transcoder converts the upstream stream into OpenAI chunk SSE; nil means - // the adapter supports non-streaming only. - Transcoder() ChatStreamTranscoder -} - -// ChatStreamTranscoder converts an upstream response stream into OpenAI -// chat.completion.chunk SSE events written to w (flushing via flush as it goes). -// It emits the terminating "data: [DONE]". Returns on upstream EOF or error. -type ChatStreamTranscoder interface { - Transcode(w io.Writer, flush func(), upstream io.Reader, meta ChatStreamMeta) error -} - -// ChatStreamMeta carries the OpenAI identity fields a transcoder stamps on every chunk. -type ChatStreamMeta struct { - ID string - Model string - Created int64 -} -``` - -- [ ] **Step 2: Write the config + options + engine wiring** - -Create `go/chat_remote.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import ( - "bytes" - "context" - "io" - "net/http" - "strconv" - "time" - - core "dappco.re/go" - - "github.com/gin-gonic/gin" -) - -// chatRemoteConfig is the remote backend attached to /v1/chat/completions via -// WithChatCompletionsRemote. It reuses the upstream router's balancer/transport. -type chatRemoteConfig struct { - reg *UpstreamRegistry - adapters map[string]ChatFormatAdapter - maxAttempts int - cooldown time.Duration - failover map[int]bool - transport http.RoundTripper - rt *upstreamTransport // built in finalise -} - -func (cfg *chatRemoteConfig) finalise() { - if cfg.cooldown <= 0 { - cfg.cooldown = defaultUpstreamCooldown - } - if cfg.failover == nil { - cfg.failover = defaultFailoverStatuses() - } - if cfg.transport == nil { - cfg.transport = http.DefaultTransport.(*http.Transport).Clone() - } - balancer := newUpstreamBalancer(cfg.cooldown, time.Now) - cfg.rt = &upstreamTransport{ - base: cfg.transport, - balancer: balancer, - maxAttempts: cfg.maxAttempts, - failover: cfg.failover, - } -} - -// dispatchRemote proxies a chat request to the resolved remote pool, applying the -// per-model adapter (or verbatim passthrough when adapter == nil). -func (h *chatCompletionsHandler) dispatchRemote(c *gin.Context, req ChatCompletionRequest, raw []byte, pool []Upstream, adapter ChatFormatAdapter) { - // Stream-capability check BEFORE dispatch (so we can still send an error body). - if req.Stream && adapter != nil && adapter.Transcoder() == nil { - writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "stream", "the adapter for this model does not support streaming", "") - return - } - - path := defaultChatCompletionsPath - body := raw - var hdrs map[string]string - if adapter != nil { - b, hh, err := adapter.BuildRequest(req) - if err != nil { - writeChatCompletionError(c, http.StatusInternalServerError, "inference_error", "model", err.Error(), "inference_error") - return - } - path, body, hdrs = adapter.UpstreamPath(), b, hh - } - - outReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, path, bytes.NewReader(body)) - if err != nil { - writeChatCompletionError(c, http.StatusInternalServerError, "inference_error", "model", err.Error(), "inference_error") - return - } - bound := body - outReq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(bound)), nil } - outReq.ContentLength = int64(len(bound)) - outReq.Header.Set("Content-Type", "application/json") - for k, v := range hdrs { - outReq.Header.Set(k, v) - } - ctx := context.WithValue(outReq.Context(), poolCtxKey, pool) - ctx = context.WithValue(ctx, keyCtxKey, req.Model) - outReq = outReq.WithContext(ctx) - - resp, err := h.remote.rt.RoundTrip(outReq) - if err != nil { - status, code := http.StatusServiceUnavailable, "upstream_unavailable" - var re *routerError - if core.As(err, &re) { - status, code = re.status, re.code - } - if status == http.StatusServiceUnavailable { - c.Header("Retry-After", strconv.Itoa(int(h.remote.cooldown.Seconds()))) - } - writeChatCompletionError(c, status, "invalid_request_error", "model", "upstream request failed", code) - return - } - defer func() { _ = resp.Body.Close() }() - - h.deliverRemote(c, req, adapter, resp) -} - -func (h *chatCompletionsHandler) deliverRemote(c *gin.Context, req ChatCompletionRequest, adapter ChatFormatAdapter, resp *http.Response) { - // Non-2xx: passthrough copies verbatim; adapter wraps in the OpenAI error shape. - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamResponseBytes)) - if adapter == nil { - c.Header("Content-Type", "application/json") - c.Status(resp.StatusCode) - _, _ = c.Writer.Write(body) - return - } - writeChatCompletionError(c, resp.StatusCode, "invalid_request_error", "model", "upstream error: "+string(body), "upstream_error") - return - } - - if req.Stream { - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Status(http.StatusOK) - flush := c.Writer.Flush - if adapter == nil { - copyFlushing(c.Writer, resp.Body, flush) - return - } - meta := ChatStreamMeta{ID: newChatCompletionID(), Model: req.Model, Created: time.Now().Unix()} - _ = adapter.Transcoder().Transcode(c.Writer, flush, resp.Body, meta) - return - } - - // Non-streaming. - body, _ := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamResponseBytes)) - if adapter == nil { - c.Header("Content-Type", "application/json") - c.Status(http.StatusOK) - _, _ = c.Writer.Write(body) - return - } - out, err := adapter.DecodeResponse(req.Model, body) - if err != nil { - writeChatCompletionError(c, http.StatusBadGateway, "invalid_request_error", "model", "could not decode upstream response", "invalid_upstream_response") - return - } - c.JSON(http.StatusOK, out) -} - -// copyFlushing streams src to dst, flushing after each read so SSE chunks reach -// the client immediately. -func copyFlushing(dst io.Writer, src io.Reader, flush func()) { - buf := make([]byte, 32*1024) - for { - n, err := src.Read(buf) - if n > 0 { - if _, werr := dst.Write(buf[:n]); werr != nil { - return - } - if flush != nil { - flush() - } - } - if err != nil { - return - } - } -} -``` - -- [ ] **Step 3: Add the options (`options.go`) + engine fields (`api.go`)** - -In `go/options.go`, after `WithChatCompletionsPath` (~line 849): - -```go -// WithChatCompletionsRemote attaches a remote backend to /v1/chat/completions. -// Compose with WithChatCompletions for hybrid (local-first); use alone for -// remote-only. Models with no WithChatModelAdapter are forwarded verbatim -// (OpenAI passthrough); adapters map non-OpenAI upstreams (see chat_adapter.go). -// -// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("10.0.0.0/8")) -// _ = reg.SetDefault(api.Upstream{URL: "https://llm.lthn.sh"}) -// api.New(api.WithChatCompletions(local), api.WithChatCompletionsRemote(reg)) -func WithChatCompletionsRemote(reg *UpstreamRegistry, opts ...ChatRemoteOption) Option { - return func(e *Engine) { - if reg == nil { - return - } - cfg := &chatRemoteConfig{reg: reg, adapters: map[string]ChatFormatAdapter{}} - for _, opt := range opts { - if opt != nil { - opt(cfg) - } - } - cfg.finalise() - e.chatRemote = cfg - } -} - -// ChatRemoteOption configures the chat remote backend. -type ChatRemoteOption func(*chatRemoteConfig) - -// WithChatModelAdapter maps a model name to a non-OpenAI format adapter. -func WithChatModelAdapter(model string, a ChatFormatAdapter) ChatRemoteOption { - return func(cfg *chatRemoteConfig) { - if core.Trim(model) != "" && a != nil { - cfg.adapters[model] = a - } - } -} - -// WithChatRemoteFailover sets max upstream attempts + per-upstream cooldown for -// the remote backend (default: len(pool), 10s). -func WithChatRemoteFailover(maxAttempts int, cooldown time.Duration) ChatRemoteOption { - return func(cfg *chatRemoteConfig) { - cfg.maxAttempts = maxAttempts - if cooldown > 0 { - cfg.cooldown = cooldown - } - } -} - -// WithChatRemoteTransport sets the base RoundTripper for remote dispatch. -func WithChatRemoteTransport(rt http.RoundTripper) ChatRemoteOption { - return func(cfg *chatRemoteConfig) { cfg.transport = rt } -} - -// WithChatCompletionsAllowRemoteClients permits non-loopback clients on the chat -// endpoint, but ONLY when a bearer is configured (WithBearerAuth) — mirrors the -// engine's ErrPublicBindNoBearer invariant. Without it, the endpoint stays -// loopback-only. Pair with an auth-guarded route for real enforcement. -func WithChatCompletionsAllowRemoteClients() Option { - return func(e *Engine) { e.chatAllowRemote = true } -} -``` - -Confirm `options.go` already imports `time`, `net/http`, `core` (it does — used by other options). - -In `go/api.go`, add to the `Engine` struct (after `upstreamRouter *upstreamRouterConfig`): - -```go - // chatRemote, when set via WithChatCompletionsRemote, adds a remote backend - // to the chat completions endpoint (local-first dispatch). - chatRemote *chatRemoteConfig - // chatAllowRemote permits non-loopback chat clients when a bearer is set. - chatAllowRemote bool -``` - -- [ ] **Step 4: Wire the handler (`chat_completions.go` + `api.go` build)** - -In `go/chat_completions.go`, replace the `chatCompletionsHandler` struct + constructor + `ServeHTTP` head with: - -```go -type chatCompletionsHandler struct { - resolver *ModelResolver - remote *chatRemoteConfig - allowRemote bool - bearerConfigured bool -} - -func newChatCompletionsHandler(resolver *ModelResolver, remote *chatRemoteConfig, allowRemote, bearerConfigured bool) *chatCompletionsHandler { - return &chatCompletionsHandler{ - resolver: resolver, - remote: remote, - allowRemote: allowRemote, - bearerConfigured: bearerConfigured, - } -} - -func (h *chatCompletionsHandler) ServeHTTP(c *gin.Context) { - if h == nil || (h.resolver == nil && h.remote == nil) { - writeChatCompletionError(c, http.StatusServiceUnavailable, "invalid_request_error", "model", "chat handler is not configured", "service_unavailable") - return - } - - if !isLoopbackRequest(c.Request) && !(h.allowRemote && h.bearerConfigured) { - writeChatCompletionError(c, http.StatusForbidden, "invalid_request_error", "request", "chat completions is only available on loopback interfaces", "") - return - } - - raw, ok := readChatBody(c) - if !ok { - return - } - var req ChatCompletionRequest - if err := decodeJSONBody(bytes.NewReader(raw), &req); err != nil { - writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", "invalid request body", "") - return - } - if err := validateChatRequest(&req); err != nil { - chatErr, isChatErr := err.(*chatCompletionRequestError) - if !isChatErr { - writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", err.Error(), "") - return - } - writeChatCompletionError(c, chatErr.Status, chatErr.Type, chatErr.Param, chatErr.Message, chatErr.Code) - return - } - - // PURE-LOCAL: unchanged current behaviour (no Knows gate). - if h.remote == nil { - h.serveLocal(c, req) - return - } - // HYBRID: local-first if the resolver knows the model; else remote. - if h.resolver != nil && h.resolver.Knows(req.Model) { - h.serveLocal(c, req) - return - } - pool, found := h.remote.reg.resolve(req.Model) - if !found { - writeChatCompletionError(c, http.StatusNotFound, "invalid_request_error", "model", "model not found: "+req.Model, "model_not_found") - return - } - h.dispatchRemote(c, req, raw, pool, h.remote.adapters[req.Model]) -} - -// readChatBody reads the bounded request body once (so it can drive both the -// selector and a verbatim upstream forward). -func readChatBody(c *gin.Context) ([]byte, bool) { - limited := http.MaxBytesReader(c.Writer, c.Request.Body, maxToolRequestBodyBytes) - body, err := io.ReadAll(limited) - if err != nil { - if err.Error() == "http: request body too large" { - writeChatCompletionError(c, http.StatusRequestEntityTooLarge, "invalid_request_error", "body", "request body too large", "") - return nil, false - } - writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", "unable to read request body", "") - return nil, false - } - return body, true -} -``` - -Then refactor the existing local logic (resolve → options → serve) from the old `ServeHTTP` body into a new method `serveLocal` (move lines that were after the decode/validate block — `resolver.ResolveModel`, `chatRequestOptions`, `normalizedStopSequences`, message conversion, stream dispatch): - -```go -func (h *chatCompletionsHandler) serveLocal(c *gin.Context, req ChatCompletionRequest) { - if h.resolver == nil { - writeChatCompletionError(c, http.StatusNotFound, "invalid_request_error", "model", "model not found: "+req.Model, "model_not_found") - return - } - model, err := h.resolver.ResolveModel(req.Model) - if err != nil { - status, errType, errCode, errParam := mapResolverError(err) - writeChatCompletionError(c, status, errType, errParam, err.Error(), errCode) - return - } - reqForOptions := req - reqForOptions.Stop = nil - options, err := chatRequestOptions(&reqForOptions) - if err != nil { - writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "stop", err.Error(), "") - return - } - stopSequences, err := normalizedStopSequences(req.Stop) - if err != nil { - writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "stop", err.Error(), "") - return - } - messages := make([]inference.Message, 0, len(req.Messages)) - for _, msg := range req.Messages { - messages = append(messages, inference.Message{Role: msg.Role, Content: msg.Content}) - } - if req.Stream { - h.serveStreaming(c, model, req, messages, stopSequences, options...) - return - } - h.serveNonStreaming(c, model, req, messages, stopSequences, options...) -} -``` - -Add `"bytes"` and `"io"` to `chat_completions.go` imports if not present (`io` likely is not — add both). - -> Confirm `decodeJSONBody(reader any, dest any)` accepts an `io.Reader` — the original `ServeHTTP` called it with `c.Request.Body` (an `io.Reader`), so `bytes.NewReader(raw)` is compatible. If it type-asserts to `io.ReadCloser` specifically, wrap with `io.NopCloser(bytes.NewReader(raw))`. - -In `go/api.go` `build()`, replace the chat-completions mount block: - -```go - // Mount the OpenAI-compatible chat completion endpoint when a local resolver - // and/or a remote backend is configured. - if e.chatCompletionsResolver != nil || e.chatRemote != nil { - path := e.chatCompletionsPath - if core.Trim(path) == "" { - path = defaultChatCompletionsPath - } - h := newChatCompletionsHandler(e.chatCompletionsResolver, e.chatRemote, e.chatAllowRemote, e.bearerConfigured) - r.POST(path, h.ServeHTTP) - } -``` - -And in `New()` (api.go ~138), broaden the default-path guard so remote-only also gets the default path: - -```go - if (e.chatCompletionsResolver != nil || e.chatRemote != nil) && core.Trim(e.chatCompletionsPath) == "" { - e.chatCompletionsPath = defaultChatCompletionsPath - } -``` - -- [ ] **Step 5: Write integration tests (`chat_remote_test.go`)** - -Create `go/chat_remote_test.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api_test - -import ( - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - api "dappco.re/go/api" -) - -// chatPost sends a chat request from a loopback client. -func chatPost(t *testing.T, base, body string) *http.Response { - t.Helper() - resp, err := http.Post(base+"/v1/chat/completions", "application/json", strings.NewReader(body)) - if err != nil { - t.Fatalf("POST: %v", err) - } - return resp -} - -func TestChatRemote_Passthrough_Good(t *testing.T) { - var gotBody string - up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, _ := io.ReadAll(r.Body) - gotBody = string(b) - _, _ = io.WriteString(w, `{"id":"x","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}`) - })) - defer up.Close() - - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.SetDefault(api.Upstream{URL: up.URL}) - e, _ := api.New(api.WithChatCompletionsRemote(reg)) - srv := httptest.NewServer(e.Handler()) - defer srv.Close() - - // Send an unmodelled field (tools) to prove verbatim passthrough fidelity. - resp := chatPost(t, srv.URL, `{"model":"gpt-x","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function"}]}`) - defer resp.Body.Close() - out, _ := io.ReadAll(resp.Body) - if !strings.Contains(gotBody, `"tools"`) { - t.Errorf("upstream did not receive verbatim body (tools dropped): %s", gotBody) - } - if !strings.Contains(string(out), `"content":"hi"`) { - t.Errorf("client did not get upstream response: %s", out) - } -} - -func TestChatRemote_UnknownModel_Bad(t *testing.T) { - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.Set("known", api.Upstream{URL: "http://127.0.0.1:1"}) // no default - e, _ := api.New(api.WithChatCompletionsRemote(reg)) - srv := httptest.NewServer(e.Handler()) - defer srv.Close() - - resp := chatPost(t, srv.URL, `{"model":"nope","messages":[{"role":"user","content":"x"}]}`) - defer resp.Body.Close() - if resp.StatusCode != http.StatusNotFound { - t.Fatalf("status = %d, want 404", resp.StatusCode) - } - body, _ := io.ReadAll(resp.Body) - if !strings.Contains(string(body), "model_not_found") { - t.Errorf("want model_not_found, got %s", body) - } -} - -func TestChatRemote_Failover_Good(t *testing.T) { - dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) })) - defer dead.Close() - live := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, `{"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`) - })) - defer live.Close() - - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.Set("m", api.Upstream{URL: dead.URL}, api.Upstream{URL: live.URL}) - e, _ := api.New(api.WithChatCompletionsRemote(reg)) - srv := httptest.NewServer(e.Handler()) - defer srv.Close() - - resp := chatPost(t, srv.URL, `{"model":"m","messages":[{"role":"user","content":"x"}]}`) - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, want 200 (failed over)", resp.StatusCode) - } -} - -func TestChatRemote_StreamingPassthrough_Good(t *testing.T) { - up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - f, _ := w.(http.Flusher) - for _, ch := range []string{"data: {\"x\":1}\n\n", "data: [DONE]\n\n"} { - _, _ = io.WriteString(w, ch) - if f != nil { - f.Flush() - } - } - })) - defer up.Close() - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.SetDefault(api.Upstream{URL: up.URL}) - e, _ := api.New(api.WithChatCompletionsRemote(reg)) - srv := httptest.NewServer(e.Handler()) - defer srv.Close() - - resp := chatPost(t, srv.URL, `{"model":"m","messages":[{"role":"user","content":"x"}],"stream":true}`) - defer resp.Body.Close() - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { - t.Fatalf("Content-Type = %q, want SSE", ct) - } - out, _ := io.ReadAll(resp.Body) - if !strings.Contains(string(out), "[DONE]") { - t.Errorf("stream not passed through: %s", out) - } -} - -func TestChatRemote_BindOptIn_Bad(t *testing.T) { - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:1"}) - // No allow-remote, no bearer: non-loopback would be rejected. We assert the - // guard logic via a loopback request still works (positive) and that the - // option+bearer path is constructed without error. - e, _ := api.New( - api.WithBearerAuth("secret"), - api.WithChatCompletionsAllowRemoteClients(), - api.WithChatCompletionsRemote(reg), - ) - srv := httptest.NewServer(e.Handler()) - defer srv.Close() - // Loopback client is always allowed regardless of opt-in. - resp := chatPost(t, srv.URL, `{"model":"m","messages":[{"role":"user","content":"x"}]}`) - defer resp.Body.Close() - // httptest client is loopback → not 403. (Off-loopback 403 is covered by the - // internal guard unit test below.) - if resp.StatusCode == http.StatusForbidden { - t.Fatalf("loopback client got 403, want allowed") - } -} -``` - -> The off-loopback 403 path is hard to exercise via httptest (always 127.0.0.1). Add an internal guard unit test in `chat_remote_internal_test.go` that calls the guard directly: - -```go -func TestChatHandler_BindGuard_Ugly(t *testing.T) { - // non-loopback remote addr, no opt-in → must be rejected. - h := newChatCompletionsHandler(nil, &chatRemoteConfig{}, false, false) - req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"m","messages":[{"role":"user","content":"x"}]}`)) - req.RemoteAddr = "203.0.113.7:5555" - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = req - h.ServeHTTP(c) - if w.Code != http.StatusForbidden { - t.Fatalf("non-loopback w/o opt-in: code = %d, want 403", w.Code) - } - // With opt-in + bearer configured → not 403 (proceeds to dispatch/404 etc.). - h2 := newChatCompletionsHandler(nil, &chatRemoteConfig{reg: NewUpstreamRegistry()}, true, true) - w2 := httptest.NewRecorder() - c2, _ := gin.CreateTestContext(w2) - r2 := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"m","messages":[{"role":"user","content":"x"}]}`)) - r2.RemoteAddr = "203.0.113.7:5555" - c2.Request = r2 - h2.ServeHTTP(c2) - if w2.Code == http.StatusForbidden { - t.Fatalf("non-loopback WITH opt-in+bearer: code = 403, want allowed") - } - - // Opt-in but NO bearer configured → still 403 (mirrors ErrPublicBindNoBearer). - h3 := newChatCompletionsHandler(nil, &chatRemoteConfig{}, true, false) - w3 := httptest.NewRecorder() - c3, _ := gin.CreateTestContext(w3) - r3 := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"m","messages":[{"role":"user","content":"x"}]}`)) - r3.RemoteAddr = "203.0.113.7:5555" - c3.Request = r3 - h3.ServeHTTP(c3) - if w3.Code != http.StatusForbidden { - t.Fatalf("non-loopback opt-in WITHOUT bearer: code = %d, want 403", w3.Code) - } -} -``` - -Add imports `net/http`, `net/http/httptest`, `strings`, `github.com/gin-gonic/gin` to `chat_remote_internal_test.go`. - -- [ ] **Step 6: Run, verify, commit** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go build ./ && GOWORK=off go test ./ -run 'TestChatRemote|TestChatHandler|TestModelResolver_Knows' -race` -Expected: PASS. - -```bash -cd /Users/snider/Code/core/api -git add go/chat_adapter.go go/chat_remote.go go/chat_remote_test.go go/chat_remote_internal_test.go go/options.go go/api.go go/chat_completions.go -git commit -m "$(printf 'feat(api): chat-completions remote backend — local-first dispatch + OpenAI passthrough\n\nCo-Authored-By: Virgil ')" -``` - ---- - -## Task 3: OllamaAdapter - -**Files:** -- Create: `go/chat_adapter_ollama.go` -- Test: `go/chat_adapter_ollama_test.go` (`package api_test`) - -- [ ] **Step 1: Write the failing tests** - -Create `go/chat_adapter_ollama_test.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api_test - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - api "dappco.re/go/api" -) - -func TestOllamaAdapter_BuildRequest_Good(t *testing.T) { - a := api.OllamaAdapter() - mt := 64 - body, hdrs, err := a.BuildRequest(api.ChatCompletionRequest{ - Model: "llama3", Messages: []api.ChatMessage{{Role: "user", Content: "hi"}}, MaxTokens: &mt, Stream: true, - }) - if err != nil { - t.Fatal(err) - } - if hdrs["Content-Type"] != "application/json" { - t.Errorf("missing content-type header") - } - var got map[string]any - _ = json.Unmarshal(body, &got) - if got["model"] != "llama3" || got["stream"] != true { - t.Errorf("bad ollama body: %s", body) - } - opts, _ := got["options"].(map[string]any) - if opts["num_predict"].(float64) != 64 { - t.Errorf("max_tokens not mapped to num_predict: %s", body) - } -} - -func TestOllamaAdapter_DecodeResponse_Good(t *testing.T) { - a := api.OllamaAdapter() - out, err := a.DecodeResponse("llama3", []byte(`{"message":{"role":"assistant","content":"4"},"done":true,"done_reason":"stop","prompt_eval_count":3,"eval_count":1}`)) - if err != nil { - t.Fatal(err) - } - if out.Choices[0].Message.Content != "4" || out.Choices[0].FinishReason != "stop" { - t.Errorf("bad decode: %+v", out) - } - if out.Usage.PromptTokens != 3 || out.Usage.CompletionTokens != 1 { - t.Errorf("bad usage: %+v", out.Usage) - } -} - -func TestOllamaAdapter_Transcode_Good(t *testing.T) { - a := api.OllamaAdapter() - stream := strings.Join([]string{ - `{"message":{"role":"assistant","content":"He"},"done":false}`, - `{"message":{"role":"assistant","content":"llo"},"done":false}`, - `{"message":{"role":"assistant","content":""},"done":true,"done_reason":"stop"}`, - }, "\n") - var buf bytes.Buffer - err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "llama3", Created: 1}) - if err != nil { - t.Fatal(err) - } - got := buf.String() - if !strings.Contains(got, `"content":"He"`) || !strings.Contains(got, `"content":"llo"`) { - t.Errorf("missing deltas: %s", got) - } - if !strings.Contains(got, `"finish_reason":"stop"`) || !strings.Contains(got, "data: [DONE]") { - t.Errorf("missing terminal/[DONE]: %s", got) - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestOllamaAdapter` -Expected: FAIL — `api.OllamaAdapter undefined`. - -- [ ] **Step 3: Implement `OllamaAdapter`** - -Create `go/chat_adapter_ollama.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import ( - "bufio" - "encoding/json" - "io" - - core "dappco.re/go" -) - -type ollamaAdapter struct{} - -// OllamaAdapter maps OpenAI chat completions to/from Ollama's native /api/chat -// (JSON request with an "options" block; newline-delimited JSON stream). -func OllamaAdapter() ChatFormatAdapter { return ollamaAdapter{} } - -func (ollamaAdapter) Name() string { return "ollama" } -func (ollamaAdapter) UpstreamPath() string { return "/api/chat" } - -func (ollamaAdapter) BuildRequest(req ChatCompletionRequest) ([]byte, map[string]string, error) { - msgs := make([]map[string]string, 0, len(req.Messages)) - for _, m := range req.Messages { - msgs = append(msgs, map[string]string{"role": m.Role, "content": m.Content}) - } - options := map[string]any{} - if req.Temperature != nil { - options["temperature"] = *req.Temperature - } - if req.TopP != nil { - options["top_p"] = *req.TopP - } - if req.TopK != nil { - options["top_k"] = *req.TopK - } - if req.MaxTokens != nil { - options["num_predict"] = *req.MaxTokens - } - body := map[string]any{ - "model": req.Model, - "messages": msgs, - "stream": req.Stream, - } - if len(options) > 0 { - body["options"] = options - } - if len(req.Stop) > 0 { - body["stop"] = []string(req.Stop) - } - raw, err := json.Marshal(body) - if err != nil { - return nil, nil, core.E("ollama", "marshal request", err) - } - return raw, map[string]string{"Content-Type": "application/json"}, nil -} - -type ollamaResponse struct { - Message struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"message"` - Done bool `json:"done"` - DoneReason string `json:"done_reason"` - PromptEvalCount int `json:"prompt_eval_count"` - EvalCount int `json:"eval_count"` -} - -func ollamaFinish(doneReason string) string { - if doneReason == "length" { - return "length" - } - return "stop" -} - -func (ollamaAdapter) DecodeResponse(model string, upstream []byte) (ChatCompletionResponse, error) { - var or ollamaResponse - if err := json.Unmarshal(upstream, &or); err != nil { - return ChatCompletionResponse{}, core.E("ollama", "decode response", err) - } - return ChatCompletionResponse{ - ID: newChatCompletionID(), - Object: "chat.completion", - Model: model, - Choices: []ChatChoice{{Index: 0, Message: ChatMessage{Role: "assistant", Content: or.Message.Content}, FinishReason: ollamaFinish(or.DoneReason)}}, - Usage: ChatUsage{PromptTokens: or.PromptEvalCount, CompletionTokens: or.EvalCount, TotalTokens: or.PromptEvalCount + or.EvalCount}, - }, nil -} - -func (ollamaAdapter) Transcoder() ChatStreamTranscoder { return ollamaTranscoder{} } - -type ollamaTranscoder struct{} - -func (ollamaTranscoder) Transcode(w io.Writer, flush func(), upstream io.Reader, meta ChatStreamMeta) error { - scanner := bufio.NewScanner(upstream) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - first := true - for scanner.Scan() { - line := core.Trim(scanner.Text()) - if line == "" { - continue - } - var or ollamaResponse - if err := json.Unmarshal([]byte(line), &or); err != nil { - continue // skip malformed line - } - if or.Done { - fr := ollamaFinish(or.DoneReason) - writeChatChunk(w, flush, ChatCompletionChunk{ - ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model, - Choices: []ChatChunkChoice{{Index: 0, Delta: ChatMessageDelta{}, FinishReason: &fr}}, - }) - break - } - delta := ChatMessageDelta{Content: or.Message.Content} - if first { - delta.Role = "assistant" - first = false - } - writeChatChunk(w, flush, ChatCompletionChunk{ - ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model, - Choices: []ChatChunkChoice{{Index: 0, Delta: delta, FinishReason: nil}}, - }) - } - writeSSEDone(w, flush) - return scanner.Err() -} -``` - -Add the shared SSE writers to `go/chat_adapter.go`: - -```go -// writeChatChunk marshals a chunk as one SSE "data:" event and flushes. -func writeChatChunk(w io.Writer, flush func(), chunk ChatCompletionChunk) { - data := core.JSONMarshal(chunk) - raw, ok := data.Value.([]byte) - if !data.OK || !ok { - return - } - _, _ = io.WriteString(w, "data: ") - _, _ = w.Write(raw) - _, _ = io.WriteString(w, "\n\n") - if flush != nil { - flush() - } -} - -// writeSSEDone emits the terminating sentinel. -func writeSSEDone(w io.Writer, flush func()) { - _, _ = io.WriteString(w, "data: [DONE]\n\n") - if flush != nil { - flush() - } -} -``` - -Add `core "dappco.re/go"` to `chat_adapter.go` imports. - -- [ ] **Step 4: Run to verify it passes** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run 'TestOllamaAdapter' -race` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -cd /Users/snider/Code/core/api -git add go/chat_adapter_ollama.go go/chat_adapter_ollama_test.go go/chat_adapter.go -git commit -m "$(printf 'feat(api): OllamaAdapter — OpenAI <-> Ollama-native /api/chat\n\nCo-Authored-By: Virgil ')" -``` - ---- - -## Task 4: AnthropicAdapter - -**Files:** -- Create: `go/chat_adapter_anthropic.go` -- Test: `go/chat_adapter_anthropic_test.go` (`package api_test`) - -- [ ] **Step 1: Write the failing tests** - -Create `go/chat_adapter_anthropic_test.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api_test - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - api "dappco.re/go/api" -) - -func TestAnthropicAdapter_BuildRequest_Good(t *testing.T) { - a := api.AnthropicAdapter() - body, hdrs, err := a.BuildRequest(api.ChatCompletionRequest{ - Model: "claude-3", Messages: []api.ChatMessage{{Role: "system", Content: "be terse"}, {Role: "user", Content: "hi"}}, - }) - if err != nil { - t.Fatal(err) - } - if hdrs["anthropic-version"] == "" { - t.Errorf("missing anthropic-version header") - } - var got map[string]any - _ = json.Unmarshal(body, &got) - if got["system"] != "be terse" { - t.Errorf("system not extracted: %s", body) - } - msgs, _ := got["messages"].([]any) - if len(msgs) != 1 { // system removed from messages - t.Errorf("system not removed from messages: %s", body) - } - if _, ok := got["max_tokens"]; !ok { - t.Errorf("max_tokens (mandatory) missing: %s", body) - } -} - -func TestAnthropicAdapter_DecodeResponse_Good(t *testing.T) { - a := api.AnthropicAdapter() - out, err := a.DecodeResponse("claude-3", []byte(`{"content":[{"type":"text","text":"Hi"},{"type":"text","text":" there"}],"stop_reason":"max_tokens","usage":{"input_tokens":5,"output_tokens":2}}`)) - if err != nil { - t.Fatal(err) - } - if out.Choices[0].Message.Content != "Hi there" { - t.Errorf("text blocks not concatenated: %q", out.Choices[0].Message.Content) - } - if out.Choices[0].FinishReason != "length" { - t.Errorf("max_tokens not mapped to length: %s", out.Choices[0].FinishReason) - } - if out.Usage.PromptTokens != 5 || out.Usage.CompletionTokens != 2 { - t.Errorf("bad usage: %+v", out.Usage) - } -} - -func TestAnthropicAdapter_Transcode_Good(t *testing.T) { - a := api.AnthropicAdapter() - // Minimal Anthropic event stream. - stream := strings.Join([]string{ - "event: message_start", - `data: {"type":"message_start","message":{"usage":{"input_tokens":5}}}`, - "", - "event: content_block_delta", - `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"He"}}`, - "", - "event: content_block_delta", - `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"llo"}}`, - "", - "event: message_delta", - `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}`, - "", - "event: message_stop", - `data: {"type":"message_stop"}`, - "", - }, "\n") - var buf bytes.Buffer - err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "claude-3", Created: 1}) - if err != nil { - t.Fatal(err) - } - got := buf.String() - if !strings.Contains(got, `"content":"He"`) || !strings.Contains(got, `"content":"llo"`) { - t.Errorf("missing deltas: %s", got) - } - if !strings.Contains(got, `"finish_reason":"stop"`) || !strings.Contains(got, "data: [DONE]") { - t.Errorf("missing terminal/[DONE]: %s", got) - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestAnthropicAdapter` -Expected: FAIL — `api.AnthropicAdapter undefined`. - -- [ ] **Step 3: Implement `AnthropicAdapter`** - -Create `go/chat_adapter_anthropic.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import ( - "bufio" - "encoding/json" - "io" - - core "dappco.re/go" -) - -const anthropicVersion = "2023-06-01" - -type anthropicAdapter struct{} - -// AnthropicAdapter maps OpenAI chat completions to/from Anthropic's /v1/messages -// (top-level system field, mandatory max_tokens, content blocks, SSE event stream). -func AnthropicAdapter() ChatFormatAdapter { return anthropicAdapter{} } - -func (anthropicAdapter) Name() string { return "anthropic" } -func (anthropicAdapter) UpstreamPath() string { return "/v1/messages" } - -func anthropicFinish(stopReason string) string { - switch stopReason { - case "max_tokens": - return "length" - default: // end_turn, stop_sequence, etc. - return "stop" - } -} - -func (anthropicAdapter) BuildRequest(req ChatCompletionRequest) ([]byte, map[string]string, error) { - var system string - msgs := make([]map[string]string, 0, len(req.Messages)) - for _, m := range req.Messages { - if m.Role == "system" { - if system != "" { - system += "\n" - } - system += m.Content - continue - } - msgs = append(msgs, map[string]string{"role": m.Role, "content": m.Content}) - } - maxTokens := chatDefaultMaxTokens - if req.MaxTokens != nil { - maxTokens = *req.MaxTokens - } - body := map[string]any{ - "model": req.Model, - "messages": msgs, - "max_tokens": maxTokens, - "stream": req.Stream, - } - if system != "" { - body["system"] = system - } - if req.Temperature != nil { - body["temperature"] = *req.Temperature - } - if req.TopP != nil { - body["top_p"] = *req.TopP - } - if req.TopK != nil { - body["top_k"] = *req.TopK - } - if len(req.Stop) > 0 { - body["stop_sequences"] = []string(req.Stop) - } - raw, err := json.Marshal(body) - if err != nil { - return nil, nil, core.E("anthropic", "marshal request", err) - } - return raw, map[string]string{"Content-Type": "application/json", "anthropic-version": anthropicVersion}, nil -} - -type anthropicResponse struct { - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` - StopReason string `json:"stop_reason"` - Usage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - } `json:"usage"` -} - -func (anthropicAdapter) DecodeResponse(model string, upstream []byte) (ChatCompletionResponse, error) { - var ar anthropicResponse - if err := json.Unmarshal(upstream, &ar); err != nil { - return ChatCompletionResponse{}, core.E("anthropic", "decode response", err) - } - var content string - for _, b := range ar.Content { - if b.Type == "text" { - content += b.Text - } - } - return ChatCompletionResponse{ - ID: newChatCompletionID(), - Object: "chat.completion", - Model: model, - Choices: []ChatChoice{{Index: 0, Message: ChatMessage{Role: "assistant", Content: content}, FinishReason: anthropicFinish(ar.StopReason)}}, - Usage: ChatUsage{PromptTokens: ar.Usage.InputTokens, CompletionTokens: ar.Usage.OutputTokens, TotalTokens: ar.Usage.InputTokens + ar.Usage.OutputTokens}, - }, nil -} - -func (anthropicAdapter) Transcoder() ChatStreamTranscoder { return anthropicTranscoder{} } - -type anthropicTranscoder struct{} - -type anthropicStreamEvent struct { - Type string `json:"type"` - Delta struct { - Type string `json:"type"` - Text string `json:"text"` - StopReason string `json:"stop_reason"` - } `json:"delta"` -} - -func (anthropicTranscoder) Transcode(w io.Writer, flush func(), upstream io.Reader, meta ChatStreamMeta) error { - scanner := bufio.NewScanner(upstream) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - first := true - stopReason := "end_turn" - for scanner.Scan() { - line := core.Trim(scanner.Text()) - if !core.HasPrefix(line, "data:") { - continue // skip "event:" and blank lines; the data line carries type - } - payload := core.Trim(line[len("data:"):]) - if payload == "" { - continue - } - var ev anthropicStreamEvent - if err := json.Unmarshal([]byte(payload), &ev); err != nil { - continue - } - switch ev.Type { - case "content_block_delta": - if ev.Delta.Type != "text_delta" || ev.Delta.Text == "" { - continue - } - delta := ChatMessageDelta{Content: ev.Delta.Text} - if first { - delta.Role = "assistant" - first = false - } - writeChatChunk(w, flush, ChatCompletionChunk{ - ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model, - Choices: []ChatChunkChoice{{Index: 0, Delta: delta, FinishReason: nil}}, - }) - case "message_delta": - if ev.Delta.StopReason != "" { - stopReason = ev.Delta.StopReason - } - case "message_stop": - fr := anthropicFinish(stopReason) - writeChatChunk(w, flush, ChatCompletionChunk{ - ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model, - Choices: []ChatChunkChoice{{Index: 0, Delta: ChatMessageDelta{}, FinishReason: &fr}}, - }) - writeSSEDone(w, flush) - return scanner.Err() - } - } - // Stream ended without an explicit message_stop — still terminate cleanly. - writeSSEDone(w, flush) - return scanner.Err() -} -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run 'TestAnthropicAdapter' -race` -Expected: PASS (3 tests). - -- [ ] **Step 5: End-to-end adapter integration test** - -Add to `go/chat_remote_test.go`: - -```go -func TestChatRemote_OllamaAdapter_E2E_Good(t *testing.T) { - up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/chat" { - t.Errorf("upstream path = %s, want /api/chat", r.URL.Path) - } - _, _ = io.WriteString(w, `{"message":{"role":"assistant","content":"pong"},"done":true,"done_reason":"stop","prompt_eval_count":2,"eval_count":1}`) - })) - defer up.Close() - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.Set("llama3", api.Upstream{URL: up.URL}) - e, _ := api.New(api.WithChatCompletionsRemote(reg, api.WithChatModelAdapter("llama3", api.OllamaAdapter()))) - srv := httptest.NewServer(e.Handler()) - defer srv.Close() - - resp := chatPost(t, srv.URL, `{"model":"llama3","messages":[{"role":"user","content":"ping"}]}`) - defer resp.Body.Close() - out, _ := io.ReadAll(resp.Body) - if !strings.Contains(string(out), `"content":"pong"`) || !strings.Contains(string(out), `"object":"chat.completion"`) { - t.Errorf("ollama not adapted to OpenAI shape: %s", out) - } -} - -func TestChatRemote_AnthropicAdapter_E2E_Good(t *testing.T) { - var gotVersion string - up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotVersion = r.Header.Get("anthropic-version") - _, _ = io.WriteString(w, `{"content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":2,"output_tokens":1}}`) - })) - defer up.Close() - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.Set("claude-3", api.Upstream{URL: up.URL}) - e, _ := api.New(api.WithChatCompletionsRemote(reg, api.WithChatModelAdapter("claude-3", api.AnthropicAdapter()))) - srv := httptest.NewServer(e.Handler()) - defer srv.Close() - - resp := chatPost(t, srv.URL, `{"model":"claude-3","messages":[{"role":"user","content":"ping"}]}`) - defer resp.Body.Close() - out, _ := io.ReadAll(resp.Body) - if gotVersion != "2023-06-01" { - t.Errorf("anthropic-version header not sent: %q", gotVersion) - } - if !strings.Contains(string(out), `"content":"pong"`) { - t.Errorf("anthropic not adapted: %s", out) - } -} -``` - -- [ ] **Step 6: Run + commit** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run 'TestAnthropicAdapter|TestChatRemote' -race` -Expected: PASS. - -```bash -cd /Users/snider/Code/core/api -git add go/chat_adapter_anthropic.go go/chat_adapter_anthropic_test.go go/chat_remote_test.go -git commit -m "$(printf 'feat(api): AnthropicAdapter — OpenAI <-> Anthropic /v1/messages + e2e adapter tests\n\nCo-Authored-By: Virgil ')" -``` - ---- - -## Task 5: Example test + QA gate + final review - -**Files:** -- Create: `go/chat_remote_example_test.go` - -- [ ] **Step 1: Example test** - -Create `go/chat_remote_example_test.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api_test - -import ( - "fmt" - - api "dappco.re/go/api" -) - -func ExampleWithChatCompletionsRemote() { - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("10.0.0.0/8")) - _ = reg.Set("llama3:70b", api.Upstream{URL: "http://10.0.0.5:11434"}) - _ = reg.SetDefault(api.Upstream{URL: "https://llm.lthn.sh"}) // OpenAI-compatible — passthrough - - engine, err := api.New( - api.WithChatCompletionsRemote(reg, - api.WithChatModelAdapter("llama3:70b", api.OllamaAdapter()), - ), - ) - if err != nil { - panic(err) - } - fmt.Println(engine.Addr()) - // Output: :8080 -} -``` - -- [ ] **Step 2: Full QA gate** - -Run: -```bash -cd /Users/snider/Code/core/api/go -gofmt -l chat_remote.go chat_adapter.go chat_adapter_ollama.go chat_adapter_anthropic.go chat_completions.go chat_remote_test.go chat_remote_internal_test.go chat_adapter_ollama_test.go chat_adapter_anthropic_test.go chat_remote_example_test.go -GOWORK=off go vet ./ -GOWORK=off go test ./ -race -count=1 -GOWORK=off go build -o /dev/null ./cmd/gateway/ -``` -Expected: `gofmt -l` empty; vet clean; full suite PASS under `-race`; gateway builds. - -- [ ] **Step 3: gosec** - -Run: `cd /Users/snider/Code/core/api/go && gosec -quiet ./ 2>/dev/null | tail -5 || echo "gosec unavailable"` -Expected: no new findings in the chat_* files (no `#nosec` needed — the SSRF-bypass annotation lives in `upstream_transport.go`, reused unchanged). - -- [ ] **Step 4: Commit** - -```bash -cd /Users/snider/Code/core/api -git add go/chat_remote_example_test.go -git commit -m "$(printf 'test(api): ExampleWithChatCompletionsRemote + QA gate\n\nCo-Authored-By: Virgil ')" || echo "nothing to commit" -``` - ---- - -## Spec coverage check - -| Spec section | Task | -|---|---| -| §4 `WithChatCompletionsRemote`, `WithChatModelAdapter`, failover/transport opts, `WithChatCompletionsAllowRemoteClients` | Task 2 | -| §4 `ChatFormatAdapter`/`ChatStreamTranscoder`/`ChatStreamMeta` | Task 2 | -| §5 dispatch flow (local-first, pure-local unchanged, remote, 404) | Task 2 | -| §5.1 `ModelResolver.Knows` | Task 1 | -| §5.2 deliver via gin `c.Writer` | Task 2 | -| §6.1 OllamaAdapter (request/non-stream/stream) | Task 3 | -| §6.2 AnthropicAdapter (request/non-stream/stream) | Task 4 | -| §7 bind opt-in + error taxonomy | Tasks 2 (bind, errors), 3/4 (adapter errors) | -| §8 testing matrix | Tasks 1–5 | -| §9 file layout | all | - -**Deferred per spec §10 (not in this plan):** generic transcoder registry, tool-calling translation, more adapters, per-model rate limiting, OpenAPI describability. diff --git a/docs/superpowers/plans/2026-06-06-openapi-inference-describability.md b/docs/superpowers/plans/2026-06-06-openapi-inference-describability.md deleted file mode 100644 index f49c77e..0000000 --- a/docs/superpowers/plans/2026-06-06-openapi-inference-describability.md +++ /dev/null @@ -1,396 +0,0 @@ -# OpenAPI Describability for the Inference Surface — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Surface the remote/hybrid chat-completions endpoint and the `WithUpstreamRouter` mounted paths in the generated OpenAPI spec (and therefore SDK gen), which today omits both. - -**Architecture:** Extend the existing special-cased-path mechanism in the spec builder — no new abstraction. Widen `ChatCompletionsEnabled` to fire for a remote backend too, and add an `UpstreamRouterPaths` field that flows engine → `TransportConfig` → `SpecBuilder`, where `Build()` emits a minimal honest `POST` proxy item per path, deduped so real items always win. - -**Tech Stack:** Go 1.26, the existing `openapi.go` SpecBuilder + `transport.go` + `spec_builder_helper.go`. Spec: `docs/superpowers/specs/2026-06-06-openapi-inference-describability-design.md`. - -**Conventions:** SPDX header on new files. UK English. `_Good/_Bad/_Ugly` test suffixes. Run from `core/api/go` with `GOWORK=off go test ./ ...`. Commit `Co-Authored-By: Virgil `. - -**Reused symbols (already in package `api` — do NOT redefine):** `SpecBuilder`, `(*Engine).OpenAPISpecBuilder()`, `(*SpecBuilder).Build([]RouteGroup)`, `chatCompletionsPathItem`, `openAPISpecPathItem`, `normaliseOpenAPIPath`, `isPublicPathForList`, `makePathItemPublic`, `operationID`, `mergeHeaders`, `standardResponseHeaders`, `rateLimitSuccessHeaders`, `mimeJSON`. Engine fields `e.chatCompletionsResolver`, `e.chatRemote` (set by `WithChatCompletionsRemote`), `e.upstreamRouter` (set by `WithUpstreamRouter`; has a `paths []string` field). `TransportConfig` + `(*Engine).TransportConfig()` in `transport.go`. Test pattern: `e.OpenAPISpecBuilder().Build(nil)` → JSON bytes (see `spec_builder_helper_test.go`). - ---- - -## File Structure - -| File | Change | -|------|--------| -| `go/transport.go` | `ChatCompletionsEnabled` fires for `e.chatRemote` too; new `UpstreamRouterPaths []string` field + population | -| `go/spec_builder_helper.go` | `builder.UpstreamRouterPaths = runtime.Transport.UpstreamRouterPaths` | -| `go/openapi.go` | `SpecBuilder.UpstreamRouterPaths`; `upstreamRouterPathItem()`; `Build()` router-path loop with dedup | -| `go/openapi_inference_test.go` | new — describability tests (`package api_test`) | - ---- - -## Task 1: Chat-completions describability (local / remote / hybrid) - -**Files:** -- Modify: `go/transport.go:53` -- Test: `go/openapi_inference_test.go` (create) - -- [ ] **Step 1: Write the failing test** - -Create `go/openapi_inference_test.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api_test - -import ( - "encoding/json" - "testing" - - api "dappco.re/go/api" -) - -// specPaths builds the engine's OpenAPI spec and returns its "paths" object. -func specPaths(t *testing.T, e *api.Engine) map[string]any { - t.Helper() - data, err := e.OpenAPISpecBuilder().Build(nil) - if err != nil { - t.Fatalf("Build: %v", err) - } - var spec map[string]any - if err := json.Unmarshal(data, &spec); err != nil { - t.Fatalf("unmarshal spec: %v", err) - } - paths, ok := spec["paths"].(map[string]any) - if !ok { - t.Fatalf("spec has no paths object") - } - return paths -} - -// postTags returns the tags of the POST operation at path, or nil. -func postTags(paths map[string]any, path string) []string { - item, ok := paths[path].(map[string]any) - if !ok { - return nil - } - post, ok := item["post"].(map[string]any) - if !ok { - return nil - } - raw, _ := post["tags"].([]any) - out := make([]string, 0, len(raw)) - for _, t := range raw { - if s, ok := t.(string); ok { - out = append(out, s) - } - } - return out -} - -func hasTag(tags []string, want string) bool { - for _, t := range tags { - if t == want { - return true - } - } - return false -} - -func TestOpenAPISpec_ChatCompletions_RemoteOnly_Good(t *testing.T) { - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - if err := reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil { - t.Fatal(err) - } - e, err := api.New(api.WithChatCompletionsRemote(reg)) - if err != nil { - t.Fatal(err) - } - paths := specPaths(t, e) - if !hasTag(postTags(paths, "/v1/chat/completions"), "inference") { - t.Fatalf("remote-only chat endpoint missing/untagged in spec; paths present: %v", keysOf(paths)) - } -} - -func TestOpenAPISpec_ChatCompletions_Absent_Good(t *testing.T) { - e, err := api.New() // neither local nor remote chat configured - if err != nil { - t.Fatal(err) - } - paths := specPaths(t, e) - if _, exists := paths["/v1/chat/completions"]; exists { - t.Fatalf("chat endpoint present in spec with no chat configured") - } -} - -func keysOf(m map[string]any) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - return out -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestOpenAPISpec_ChatCompletions` -Expected: `TestOpenAPISpec_ChatCompletions_RemoteOnly_Good` FAILS (chat path absent — `ChatCompletionsEnabled` is false for remote-only); `_Absent_Good` passes. - -- [ ] **Step 3: Widen the enabling condition** - -In `go/transport.go`, in `TransportConfig()`, change line 53 from: - -```go - ChatCompletionsEnabled: e.chatCompletionsResolver != nil, -``` -to: -```go - ChatCompletionsEnabled: e.chatCompletionsResolver != nil || e.chatRemote != nil, -``` - -The `ChatCompletionsPath` resolution (line 73-75) already fires for `core.Trim(e.chatCompletionsPath) != ""`, and `New()` sets the default path when a resolver OR remote backend is configured, so the path is already correct — only the enabled flag needed widening. - -- [ ] **Step 4: Run to verify it passes** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestOpenAPISpec_ChatCompletions -race` -Expected: both PASS. - -- [ ] **Step 5: Commit** - -```bash -cd /Users/snider/Code/core/api -git add go/transport.go go/openapi_inference_test.go -git commit -m "$(printf 'feat(api): OpenAPI spec includes chat-completions for remote/hybrid backends\n\nCo-Authored-By: Virgil ')" -``` - ---- - -## Task 2: Upstream router path items - -**Files:** -- Modify: `go/transport.go` (struct + population), `go/spec_builder_helper.go`, `go/openapi.go` -- Test: `go/openapi_inference_test.go` (extend) - -- [ ] **Step 1: Write the failing tests** - -Append to `go/openapi_inference_test.go`: - -```go -func TestOpenAPISpec_RouterPaths_Good(t *testing.T) { - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - if err := reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil { - t.Fatal(err) - } - e, err := api.New(api.WithUpstreamRouter(reg, api.WithRouterPaths("/v1/embeddings", "/v1/score"))) - if err != nil { - t.Fatal(err) - } - paths := specPaths(t, e) - for _, p := range []string{"/v1/embeddings", "/v1/score"} { - if !hasTag(postTags(paths, p), "proxy") { - t.Fatalf("router path %s missing/untagged in spec; paths: %v", p, keysOf(paths)) - } - item := paths[p].(map[string]any) - post := item["post"].(map[string]any) - responses := post["responses"].(map[string]any) - for _, code := range []string{"404", "503"} { - if _, ok := responses[code]; !ok { - t.Errorf("router path %s missing %s response", p, code) - } - } - } -} - -func TestOpenAPISpec_RouterDedupChat_Ugly(t *testing.T) { - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - if err := reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil { - t.Fatal(err) - } - // Router mounted at the default chat path AND chat enabled (remote). - e, err := api.New( - api.WithChatCompletionsRemote(reg), - api.WithUpstreamRouter(reg), // default WithRouterPaths == /v1/chat/completions - ) - if err != nil { - t.Fatal(err) - } - paths := specPaths(t, e) - tags := postTags(paths, "/v1/chat/completions") - if !hasTag(tags, "inference") { - t.Fatalf("chat path lost its inference item to the proxy dedup; tags=%v", tags) - } - if hasTag(tags, "proxy") { - t.Fatalf("chat path was clobbered by the proxy item; tags=%v", tags) - } -} -``` - -- [ ] **Step 2: Run to verify they fail** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestOpenAPISpec_Router` -Expected: `_RouterPaths_Good` FAILS (router paths absent). `_RouterDedupChat_Ugly` passes already (no proxy item exists yet, so the chat item is intact) — it locks in the dedup once Step 3 lands. - -- [ ] **Step 3: Add the `UpstreamRouterPaths` field + population (`transport.go`)** - -In `go/transport.go`, add to the `TransportConfig` struct (after `OpenAPISpecPath string`): - -```go - UpstreamRouterPaths []string -``` - -In `TransportConfig()`, after the `cfg.OpenAPISpecPath` block (around line 78), add: - -```go - if e.upstreamRouter != nil { - cfg.UpstreamRouterPaths = append([]string(nil), e.upstreamRouter.paths...) - } -``` - -- [ ] **Step 4: Pass it into the builder (`spec_builder_helper.go`)** - -In `go/spec_builder_helper.go`, after `builder.OpenAPISpecPath = runtime.Transport.OpenAPISpecPath` (line 84), add: - -```go - builder.UpstreamRouterPaths = runtime.Transport.UpstreamRouterPaths -``` - -- [ ] **Step 5: Add the SpecBuilder field + the path item + the Build loop (`openapi.go`)** - -In `go/openapi.go`, add to the `SpecBuilder` struct (after `OpenAPISpecPath string`): - -```go - UpstreamRouterPaths []string -``` - -Add the path-item builder (place it next to `openAPISpecPathItem`): - -```go -// upstreamRouterPathItem documents a WithUpstreamRouter mounted path as a -// minimal, honest POST proxy operation. The router proxies arbitrary shapes by -// selector key, so request/response schemas are generic by design; the path is -// tagged "proxy" to distinguish it from the typed "inference" chat endpoint. -func upstreamRouterPathItem(path string, operationIDs map[string]int) map[string]any { - successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) - errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()) - genericObject := func() map[string]any { - return map[string]any{"type": "object", "additionalProperties": true} - } - - return map[string]any{ - "post": map[string]any{ - "summary": "Upstream router (selector-routed proxy)", - "description": "Selector-routed reverse proxy. The request body must carry the selector field (default \"model\"); the concrete request and response schemas depend on the target upstream/model. Streams Server-Sent Events when the upstream does.", - "tags": []string{"proxy"}, - "operationId": operationID("post", path, operationIDs), - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{ - mimeJSON: map[string]any{"schema": genericObject()}, - }, - }, - "responses": map[string]any{ - "200": map[string]any{ - "description": "Proxied upstream response", - "content": map[string]any{ - mimeJSON: map[string]any{"schema": genericObject()}, - "text/event-stream": map[string]any{"schema": map[string]any{"type": "string"}}, - }, - "headers": successHeaders, - }, - "404": map[string]any{ - "description": "No upstream registered for the selector key", - "content": map[string]any{mimeJSON: map[string]any{"schema": genericObject()}}, - "headers": errorHeaders, - }, - "503": map[string]any{ - "description": "All upstreams unavailable", - "content": map[string]any{mimeJSON: map[string]any{"schema": genericObject()}}, - "headers": mergeHeaders(errorHeaders, map[string]any{ - "Retry-After": map[string]any{ - "description": "Seconds to wait before retrying.", - "schema": map[string]any{"type": "integer"}, - }, - }), - }, - }, - }, - } -} -``` - -In `Build()`, **immediately after the `for _, g := range groups { ... }` loop closes** (so the dedup covers group-contributed paths too), add: - -```go - for _, rawPath := range sb.UpstreamRouterPaths { - routerPath := normaliseOpenAPIPath(rawPath) - if routerPath == "" { - continue - } - if _, exists := paths[routerPath]; exists { - continue // a real item (chat, spec, swagger, or a group) already documents this path - } - item := upstreamRouterPathItem(routerPath, operationIDs) - if isPublicPathForList(routerPath, publicPaths) { - makePathItemPublic(item) - } - paths[routerPath] = item - } -``` - -> Note: `upstreamRouterPathItem` does NOT hard-code `"security"`. Public paths get `makePathItemPublic` applied (matching the other items); non-public paths inherit the document's global security — which is the intended "honour configured public paths, don't force-public" behaviour from spec §3.2. - -- [ ] **Step 6: Run to verify it passes** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestOpenAPISpec -race` -Expected: all 4 PASS (`_RemoteOnly_Good`, `_Absent_Good`, `_RouterPaths_Good`, `_RouterDedupChat_Ugly`). - -- [ ] **Step 7: Commit** - -```bash -cd /Users/snider/Code/core/api -git add go/transport.go go/spec_builder_helper.go go/openapi.go go/openapi_inference_test.go -git commit -m "$(printf 'feat(api): OpenAPI spec documents WithUpstreamRouter paths (deduped proxy items)\n\nCo-Authored-By: Virgil ')" -``` - ---- - -## Task 3: QA gate + final review - -**Files:** none (verification only) - -- [ ] **Step 1: Full QA gate** - -Run: -```bash -cd /Users/snider/Code/core/api/go -gofmt -l transport.go openapi.go spec_builder_helper.go openapi_inference_test.go -GOWORK=off go vet ./ -GOWORK=off go test ./ -race -count=1 -GOWORK=off go build -o /dev/null ./cmd/gateway/ -``` -Expected: `gofmt -l` empty; vet clean; full suite PASS under `-race` (no regression to the ~1686 existing tests, esp. the existing `openapi_test.go` / `spec_builder_helper_test.go`); gateway builds. - -- [ ] **Step 2: OpenAPI 3.1 validity sanity** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run 'TestSpec|TestOpenAPI|TestSwagger' -count=1` -Expected: PASS — the existing spec-shape/validity tests still hold with the new path items present. - -- [ ] **Step 3: Commit any formatting fixes** - -```bash -cd /Users/snider/Code/core/api -git add -A go/ && git commit -m "$(printf 'chore(api): gofmt pass for inference describability\n\nCo-Authored-By: Virgil ')" || echo "nothing to commit" -``` - ---- - -## Spec coverage check - -| Spec section | Task | -|---|---| -| §3.1 chat-completions for local/remote/hybrid | Task 1 | -| §3.2 minimal router proxy item (POST, `proxy` tag, generic schema, 404/503+Retry-After) | Task 2 (Step 5) | -| §3.3 dedup (real items win; router-at-chat-path → inference item) | Task 2 (Build loop + `_RouterDedupChat_Ugly`) | -| §4 wiring (transport → spec_builder_helper → openapi.go) | Tasks 1, 2 | -| §5 testing matrix | Tasks 1, 2 (+ §5 OpenAPI-validity reuse in Task 3) | -| §6 file layout | all | - -**Deferred per spec §7 (not in this plan):** real per-path schemas via consumer `RouteDescription`s, per-model enumeration, broader un-described-route sweep. diff --git a/docs/superpowers/plans/2026-06-06-upstream-router.md b/docs/superpowers/plans/2026-06-06-upstream-router.md deleted file mode 100644 index f0d1b66..0000000 --- a/docs/superpowers/plans/2026-06-06-upstream-router.md +++ /dev/null @@ -1,1601 +0,0 @@ -# Upstream Router (`WithUpstreamRouter`) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a selector-keyed reverse-proxy Option (`WithUpstreamRouter`) to `dappco.re/go/api` that load-balances each request across a runtime-mutable pool of HTTP upstreams, with weighted round-robin + passive failover, hybrid streaming, a decision hook, and composition with the existing TransformerIn/Out layer. - -**Architecture:** A copy-on-write `UpstreamRegistry` (key→pool) is the source of truth and validates URLs at registration (block-by-default SSRF, opt-in `AllowPrivateUpstreams`). A pure `upstreamBalancer` does smooth weighted round-robin + cooldown. An `upstreamTransport` (`http.RoundTripper`) owns per-attempt selection + failover. One `httputil.ReverseProxy` per router does streaming (`FlushInterval:-1`), buffered `TransformerOut` (`ModifyResponse`), and clean error envelopes (`ErrorHandler`). Mounted at the gin root by `Engine.build()`, so engine middleware (auth/CORS/rate-limit/tracing) wraps it. - -**Tech Stack:** Go 1.26, `net/http/httputil`, `gin`, `dappco.re/go` (core), existing `transformer*.go` / `ssrf_guard.go` / `response.go` helpers. Reference implementation for proxy mechanics: `go/pkg/provider/proxy.go`. Spec: `docs/superpowers/specs/2026-06-06-upstream-router-design.md`. - -**Conventions:** SPDX header `// SPDX-License-Identifier: EUPL-1.2` on every file. UK English in strings/docs. `_Good/_Bad/_Ugly` test suffixes. Run tests with `GOWORK=off go test` from `core/api/go`. Commit with `Co-Authored-By: Virgil `. - ---- - -## File Structure - -| File | Responsibility | -|------|----------------| -| `go/upstream_registry.go` | `Upstream`, `UpstreamRegistry` (COW), `RegistryOption`, `AllowPrivateUpstreams`, registration-time validation | -| `go/upstream_balancer.go` | `upstreamBalancer` — smooth weighted RR + per-URL cooldown, injectable clock | -| `go/upstream_transport.go` | `upstreamTransport` — `http.RoundTripper` doing per-attempt selection + failover | -| `go/upstream_router.go` | `Selector`, `RouteFunc`, default selector, `upstreamRouterConfig`, `UpstreamRouterOption`, handler + `httputil.ReverseProxy` assembly, `routerError`, ctx keys | -| `go/options.go` (modify) | `WithUpstreamRouter` + the `UpstreamRouterOption` helpers | -| `go/api.go` (modify) | `Engine.upstreamRouter` field; mount in `build()` | -| Tests | `upstream_registry_test.go`, `upstream_balancer_internal_test.go`, `upstream_transport_internal_test.go`, `upstream_router_test.go`, `upstream_router_example_test.go` | - -Error codes are defined as `const` at the top of `upstream_router.go` (not `string_constants.go`, which is for cross-file shared literals — these are router-local). - ---- - -## Task 1: `Upstream` + `UpstreamRegistry` (COW + validation) - -**Files:** -- Create: `go/upstream_registry.go` -- Test: `go/upstream_registry_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `go/upstream_registry_test.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api_test - -import ( - "sync" - "testing" - - api "dappco.re/go/api" -) - -func TestUpstreamRegistry_Good(t *testing.T) { - reg := api.NewUpstreamRegistry() - if err := reg.Set("lemma", api.Upstream{URL: "https://a.example.com:8000", Weight: 2}); err != nil { - t.Fatalf("Set: %v", err) - } - if err := reg.Add("lemma", api.Upstream{URL: "https://b.example.com"}); err != nil { - t.Fatalf("Add: %v", err) - } - if err := reg.SetDefault(api.Upstream{URL: "https://fallback.example.com"}); err != nil { - t.Fatalf("SetDefault: %v", err) - } - keys := reg.Keys() - if len(keys) != 1 || keys[0] != "lemma" { - t.Fatalf("Keys = %v, want [lemma]", keys) - } -} - -func TestUpstreamRegistry_Bad(t *testing.T) { - reg := api.NewUpstreamRegistry() - cases := map[string]string{ - "scheme": "ftp://a.example.com", - "no-host": "http://", - "bad-port": "http://a.example.com:99999", - "creds": "http://user:pass@a.example.com", - "loopback": "http://127.0.0.1:11434", - "private": "http://10.0.0.5:8000", - "metadata": "http://169.254.169.254", - } - for name, raw := range cases { - if err := reg.Set("k", api.Upstream{URL: raw}); err == nil { - t.Errorf("%s: Set(%q) = nil error, want rejection", name, raw) - } - } -} - -func TestUpstreamRegistry_AllowPrivate_Good(t *testing.T) { - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - if err := reg.Set("local", api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil { - t.Fatalf("Set loopback with allow-list: %v", err) - } - // Metadata stays hard-blocked even with a broad allow-list. - reg2 := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("0.0.0.0/0")) - if err := reg2.Set("meta", api.Upstream{URL: "http://169.254.169.254"}); err == nil { - t.Fatal("metadata host accepted under broad allow-list, want rejection") - } -} - -func TestUpstreamRegistry_Ugly_ConcurrentWriteSnapshot(t *testing.T) { - reg := api.NewUpstreamRegistry() - _ = reg.Set("k", api.Upstream{URL: "https://a.example.com"}) - var wg sync.WaitGroup - for i := 0; i < 50; i++ { - wg.Add(2) - go func() { defer wg.Done(); _ = reg.Add("k", api.Upstream{URL: "https://b.example.com"}) }() - go func() { defer wg.Done(); _ = reg.Keys() }() - } - wg.Wait() -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamRegistry` -Expected: FAIL — `undefined: api.NewUpstreamRegistry`, `api.Upstream`, `api.AllowPrivateUpstreams`. - -- [ ] **Step 3: Write the implementation** - -Create `go/upstream_registry.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import ( - "net" // Note: AX-6 — net.ParseIP/ParseCIDR are structural for SSRF IP-range checks. - "net/url" // Note: AX-6 — url.URL fields are structural for upstream URL validation. - "sort" - "strconv" - "sync" - "sync/atomic" - - core "dappco.re/go" -) - -// Upstream is one backend endpoint in a routing pool. -// -// Example: -// -// api.Upstream{URL: "http://10.0.0.5:8000", Weight: 2} -type Upstream struct { - URL string // http(s) base URL; validated at registration - Weight int // weighted round-robin weight; <=0 treated as 1 - Headers map[string]string // static headers injected on dispatch (e.g. upstream API key) -} - -// registrySnapshot is the immutable read-side view swapped atomically on writes. -type registrySnapshot struct { - pools map[string][]Upstream - deflt []Upstream -} - -// UpstreamRegistry is the runtime-mutable, thread-safe pool table consumed by -// WithUpstreamRouter. Reads are lock-free (atomic snapshot load); writes take a -// mutex, clone, mutate, and swap (copy-on-write). -// -// Example: -// -// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) -// _ = reg.Set("lemma", api.Upstream{URL: "http://127.0.0.1:11434"}) -type UpstreamRegistry struct { - mu sync.Mutex - snap atomic.Pointer[registrySnapshot] - allow []*net.IPNet - cidrErr error -} - -// RegistryOption configures registration-time validation policy. -type RegistryOption func(*UpstreamRegistry) - -// AllowPrivateUpstreams permits the given private/loopback/reserved CIDRs to -// pass registration validation. Without it the registry denies loopback, -// private, link-local, reserved, and metadata destinations by default. Metadata -// hosts stay hard-blocked regardless of the allow-list. -// -// Example: -// -// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8", "10.0.0.0/8")) -func AllowPrivateUpstreams(cidrs ...string) RegistryOption { - return func(r *UpstreamRegistry) { - for _, raw := range cidrs { - raw = core.Trim(raw) - if raw == "" { - continue - } - _, network, err := net.ParseCIDR(raw) - if err != nil { - if r.cidrErr == nil { - r.cidrErr = core.E("UpstreamRegistry", "invalid AllowPrivateUpstreams CIDR "+raw, err) - } - continue - } - r.allow = append(r.allow, network) - } - } -} - -// NewUpstreamRegistry creates an empty registry. Apply AllowPrivateUpstreams to -// widen the default-deny validation policy. -func NewUpstreamRegistry(opts ...RegistryOption) *UpstreamRegistry { - r := &UpstreamRegistry{} - for _, opt := range opts { - if opt != nil { - opt(r) - } - } - r.snap.Store(®istrySnapshot{pools: map[string][]Upstream{}}) - return r -} - -// Set replaces the pool for key. Returns an error (without mutating) if any -// upstream URL fails validation. -func (r *UpstreamRegistry) Set(key string, ups ...Upstream) error { - if err := r.validateAll(ups); err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - next := r.clone() - next.pools[key] = cloneUpstreams(ups) - r.snap.Store(next) - return nil -} - -// Add appends one upstream to the pool for key. -func (r *UpstreamRegistry) Add(key string, up Upstream) error { - if err := r.validate(up); err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - next := r.clone() - next.pools[key] = append(cloneUpstreams(next.pools[key]), up) - r.snap.Store(next) - return nil -} - -// Remove drops the pool for key. -func (r *UpstreamRegistry) Remove(key string) { - r.mu.Lock() - defer r.mu.Unlock() - next := r.clone() - delete(next.pools, key) - r.snap.Store(next) -} - -// SetDefault sets the fallback pool used when a key has no explicit pool. -func (r *UpstreamRegistry) SetDefault(ups ...Upstream) error { - if err := r.validateAll(ups); err != nil { - return err - } - r.mu.Lock() - defer r.mu.Unlock() - next := r.clone() - next.deflt = cloneUpstreams(ups) - r.snap.Store(next) - return nil -} - -// Keys returns the sorted set of explicitly-registered pool keys. -func (r *UpstreamRegistry) Keys() []string { - snap := r.snap.Load() - keys := make([]string, 0, len(snap.pools)) - for k := range snap.pools { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -// resolve returns the pool for key (or the default pool) and whether one exists. -func (r *UpstreamRegistry) resolve(key string) ([]Upstream, bool) { - snap := r.snap.Load() - if pool, ok := snap.pools[key]; ok && len(pool) > 0 { - return pool, true - } - if len(snap.deflt) > 0 { - return snap.deflt, true - } - return nil, false -} - -func (r *UpstreamRegistry) clone() *registrySnapshot { - cur := r.snap.Load() - next := ®istrySnapshot{ - pools: make(map[string][]Upstream, len(cur.pools)), - deflt: cur.deflt, - } - for k, v := range cur.pools { - next.pools[k] = v - } - return next -} - -func (r *UpstreamRegistry) validateAll(ups []Upstream) error { - if len(ups) == 0 { - return core.E("UpstreamRegistry", "pool must contain at least one upstream", nil) - } - for _, up := range ups { - if err := r.validate(up); err != nil { - return err - } - } - return nil -} - -func (r *UpstreamRegistry) validate(up Upstream) error { - if r.cidrErr != nil { - return r.cidrErr - } - return validateUpstreamURL(up.URL, r.allow) -} - -// validateUpstreamURL enforces the block-by-default registration policy, reusing -// the root SSRF primitives (allowedSchemes, metadataHosts, blockedIPReason). -// Non-metadata hostnames are accepted without registration-time DNS (trusted -// config). IP literals in a denied range are rejected unless covered by allow. -func validateUpstreamURL(rawURL string, allow []*net.IPNet) error { - rawURL = core.Trim(rawURL) - if rawURL == "" { - return core.E("UpstreamRegistry", "upstream URL is required", nil) - } - u, err := url.Parse(rawURL) - if err != nil { - return core.E("UpstreamRegistry", "invalid upstream URL "+rawURL, err) - } - if u.User != nil { - return core.E("UpstreamRegistry", "upstream URL must not include credentials: "+rawURL, nil) - } - if _, ok := allowedSchemes[core.Lower(u.Scheme)]; !ok { - return core.E("UpstreamRegistry", "upstream URL scheme must be http or https: "+rawURL, nil) - } - host := u.Hostname() - if host == "" { - return core.E("UpstreamRegistry", "upstream URL must include a host: "+rawURL, nil) - } - if port := u.Port(); port != "" { - n, perr := strconv.Atoi(port) - if perr != nil || n < 1 || n > 65535 { - return core.E("UpstreamRegistry", "upstream URL port is invalid: "+rawURL, perr) - } - } - if _, ok := metadataHosts[core.Lower(host)]; ok { - return core.E("UpstreamRegistry", "metadata host is not permitted: "+host, nil) - } - if ip := net.ParseIP(host); ip != nil { - if reason := blockedIPReason(ip); reason != "" && !ipAllowed(ip, allow) { - return core.E("UpstreamRegistry", reason+" not permitted (use AllowPrivateUpstreams): "+host, nil) - } - } - return nil -} - -func ipAllowed(ip net.IP, allow []*net.IPNet) bool { - for _, network := range allow { - if network.Contains(ip) { - return true - } - } - return false -} - -func cloneUpstreams(ups []Upstream) []Upstream { - if len(ups) == 0 { - return nil - } - out := make([]Upstream, len(ups)) - copy(out, ups) - return out -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamRegistry -race` -Expected: PASS (all four tests, no data race). - -- [ ] **Step 5: Commit** - -```bash -cd /Users/snider/Code/core/api -git add go/upstream_registry.go go/upstream_registry_test.go -git commit -m "$(printf 'feat(api): UpstreamRegistry — COW pool table + registration SSRF policy\n\nCo-Authored-By: Virgil ')" -``` - ---- - -## Task 2: `upstreamBalancer` (weighted RR + cooldown) - -**Files:** -- Create: `go/upstream_balancer.go` -- Test: `go/upstream_balancer_internal_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `go/upstream_balancer_internal_test.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import ( - "testing" - "time" -) - -func TestUpstreamBalancer_WeightedSpread_Good(t *testing.T) { - b := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) }) - pool := []Upstream{{URL: "a", Weight: 2}, {URL: "b", Weight: 1}} - counts := map[string]int{} - for i := 0; i < 30; i++ { - up, ok := b.pick("k", pool) - if !ok { - t.Fatal("pick returned !ok with healthy pool") - } - counts[up.URL]++ - } - if counts["a"] != 20 || counts["b"] != 10 { - t.Fatalf("weighted spread = %v, want a:20 b:10", counts) - } -} - -func TestUpstreamBalancer_CooldownSkip_Good(t *testing.T) { - now := time.Unix(1000, 0) - clock := func() time.Time { return now } - b := newUpstreamBalancer(10*time.Second, clock) - pool := []Upstream{{URL: "a", Weight: 1}, {URL: "b", Weight: 1}} - - b.markFailed("a") - for i := 0; i < 5; i++ { - up, ok := b.pick("k", pool) - if !ok || up.URL != "b" { - t.Fatalf("during cooldown got (%v,%v), want b", up.URL, ok) - } - } - now = now.Add(11 * time.Second) // cooldown elapsed - seen := map[string]bool{} - for i := 0; i < 10; i++ { - up, _ := b.pick("k", pool) - seen[up.URL] = true - } - if !seen["a"] { - t.Fatal("a not picked after cooldown elapsed") - } -} - -func TestUpstreamBalancer_AllCooling_Bad(t *testing.T) { - b := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) }) - pool := []Upstream{{URL: "a"}, {URL: "b"}} - b.markFailed("a") - b.markFailed("b") - if _, ok := b.pick("k", pool); ok { - t.Fatal("pick returned ok with all upstreams cooling") - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamBalancer` -Expected: FAIL — `undefined: newUpstreamBalancer`. - -- [ ] **Step 3: Write the implementation** - -Create `go/upstream_balancer.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import ( - "sync" - "time" -) - -// upstreamBalancer performs smooth weighted round-robin selection over a pool, -// skipping upstreams in a cooldown window after a failure. State (per-key -// current weights, per-URL cooldown) is shared across requests behind a mutex — -// a failed upstream cools for every caller. The now func is injectable for tests. -type upstreamBalancer struct { - mu sync.Mutex - current map[string]map[string]int // key -> url -> SWRR current weight - cooldown map[string]time.Time // url -> cooling-until (global across keys) - cool time.Duration - now func() time.Time -} - -func newUpstreamBalancer(cool time.Duration, now func() time.Time) *upstreamBalancer { - if now == nil { - now = time.Now - } - return &upstreamBalancer{ - current: map[string]map[string]int{}, - cooldown: map[string]time.Time{}, - cool: cool, - now: now, - } -} - -// pick selects the next upstream for key via smooth weighted round-robin over the -// non-cooling members of pool. Returns false when every member is cooling. -func (b *upstreamBalancer) pick(key string, pool []Upstream) (Upstream, bool) { - b.mu.Lock() - defer b.mu.Unlock() - - t := b.now() - cw := b.current[key] - if cw == nil { - cw = map[string]int{} - b.current[key] = cw - } - - bestIdx, total := -1, 0 - for i := range pool { - up := pool[i] - if until, ok := b.cooldown[up.URL]; ok && t.Before(until) { - continue - } - w := up.Weight - if w <= 0 { - w = 1 - } - cw[up.URL] += w - total += w - if bestIdx == -1 || cw[up.URL] > cw[pool[bestIdx].URL] { - bestIdx = i - } - } - if bestIdx == -1 { - return Upstream{}, false - } - cw[pool[bestIdx].URL] -= total - return pool[bestIdx], true -} - -// markFailed puts url into a cooldown window starting now. -func (b *upstreamBalancer) markFailed(url string) { - b.mu.Lock() - defer b.mu.Unlock() - b.cooldown[url] = b.now().Add(b.cool) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamBalancer -race` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -cd /Users/snider/Code/core/api -git add go/upstream_balancer.go go/upstream_balancer_internal_test.go -git commit -m "$(printf 'feat(api): upstreamBalancer — smooth weighted RR + cooldown\n\nCo-Authored-By: Virgil ')" -``` - ---- - -## Task 3: `upstreamTransport` (failover RoundTripper) - -**Files:** -- Create: `go/upstream_transport.go` -- Test: `go/upstream_transport_internal_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `go/upstream_transport_internal_test.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import ( - "context" - "io" - "net/http" - "strings" - "testing" - "time" -) - -type fakeRoundTripper struct { - fn func(*http.Request) (*http.Response, error) -} - -func (f fakeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f.fn(r) } - -func newResp(status int) *http.Response { - return &http.Response{ - StatusCode: status, - Body: io.NopCloser(strings.NewReader("ok")), - Header: http.Header{}, - } -} - -func requestWithPool(pool []Upstream, key string) *http.Request { - req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("{}")) - req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("{}")), nil } - ctx := context.WithValue(req.Context(), poolCtxKey, pool) - ctx = context.WithValue(ctx, keyCtxKey, key) - return req.WithContext(ctx) -} - -func TestUpstreamTransport_FailoverThenSuccess_Good(t *testing.T) { - bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) }) - var hits []string - base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) { - hits = append(hits, r.URL.Host) - if r.URL.Host == "a" { - return newResp(http.StatusBadGateway), nil - } - return newResp(http.StatusOK), nil - }} - tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 2, failover: defaultFailoverStatuses()} - pool := []Upstream{{URL: "http://a", Weight: 1}, {URL: "http://b", Weight: 1}} - - resp, err := tr.RoundTrip(requestWithPool(pool, "k")) - if err != nil { - t.Fatalf("RoundTrip: %v", err) - } - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, want 200", resp.StatusCode) - } - if len(hits) != 2 { - t.Fatalf("attempts = %v, want 2 (a then b)", hits) - } -} - -func TestUpstreamTransport_HeaderInjection_Good(t *testing.T) { - bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) }) - var gotAuth string - base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) { - gotAuth = r.Header.Get("Authorization") - return newResp(http.StatusOK), nil - }} - tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 1, failover: defaultFailoverStatuses()} - pool := []Upstream{{URL: "http://a", Headers: map[string]string{"Authorization": "Bearer up-key"}}} - - if _, err := tr.RoundTrip(requestWithPool(pool, "k")); err != nil { - t.Fatalf("RoundTrip: %v", err) - } - if gotAuth != "Bearer up-key" { - t.Fatalf("injected auth = %q, want Bearer up-key", gotAuth) - } -} - -func TestUpstreamTransport_AllFail_Bad(t *testing.T) { - bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) }) - base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) { - return newResp(http.StatusServiceUnavailable), nil - }} - tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 2, failover: defaultFailoverStatuses()} - pool := []Upstream{{URL: "http://a"}, {URL: "http://b"}} - - _, err := tr.RoundTrip(requestWithPool(pool, "k")) - var re *routerError - if !core.As(err, &re) || re.status != http.StatusServiceUnavailable { - t.Fatalf("err = %v, want *routerError status 503", err) - } -} -``` - -> Note: `core.As`, `routerError`, `poolCtxKey`, `keyCtxKey`, and `defaultFailoverStatuses` are defined in Task 4's `upstream_router.go`. This test file will not compile until Task 4 lands. Implement Task 3's production file now; if running tests before Task 4, expect a compile error naming those symbols (that IS the failing state). Otherwise reorder to write Task 4's `upstream_router.go` symbol stubs first — the recommended path is to do Steps 3 of Task 3 and Task 4 together, then run both test suites. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamTransport` -Expected: FAIL — `undefined: upstreamTransport` (and `routerError`/`poolCtxKey`/etc. until Task 4). - -- [ ] **Step 3: Write the implementation** - -Create `go/upstream_transport.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import ( - "net/http" - "net/url" // Note: AX-6 — url.URL fields are structural for per-attempt upstream rewriting. - - core "dappco.re/go" -) - -// upstreamTransport is the http.RoundTripper that owns weighted selection and -// passive failover. The per-request pool and key are read from the request -// context (bound by the router handler). On a transport error or a failover -// status it marks the upstream cooling and retries the next, up to maxAttempts. -// -// SECURITY: this transport intentionally dispatches to operator-configured -// upstreams without re-applying the request-time SSRF guard. Upstream URLs are -// validated once at registration (UpstreamRegistry.validate, default-deny with -// AllowPrivateUpstreams opt-in), so loopback/private model endpoints are -// permitted by design. See spec §8. -type upstreamTransport struct { - base http.RoundTripper - balancer *upstreamBalancer - maxAttempts int - failover map[int]bool -} - -func (t *upstreamTransport) RoundTrip(req *http.Request) (*http.Response, error) { - pool, ok := poolFromContext(req.Context()) - if !ok || len(pool) == 0 { - return nil, &routerError{status: http.StatusServiceUnavailable, code: errCodeUpstreamUnavailable, message: "no upstream pool bound to request"} - } - key, _ := keyFromContext(req.Context()) - - attempts := t.maxAttempts - if attempts <= 0 || attempts > len(pool) { - attempts = len(pool) - } - - var lastErr error - for i := 0; i < attempts; i++ { - up, ok := t.balancer.pick(key, pool) - if !ok { - break - } - target, err := url.Parse(up.URL) - if err != nil { - t.balancer.markFailed(up.URL) - lastErr = err - continue - } - - out := req.Clone(req.Context()) - if out.GetBody != nil { - if body, berr := out.GetBody(); berr == nil { - out.Body = body - } - } - applyUpstream(out, target) - for k, v := range up.Headers { - out.Header.Set(k, v) - } - - //#nosec G107 -- upstream is operator-configured and validated at registration - // (UpstreamRegistry default-deny + AllowPrivateUpstreams opt-in); the request-time - // SSRF guard is deliberately not re-applied here. See spec §8 / Mantis upstream-router. - resp, err := t.base.RoundTrip(out) - if err != nil { - t.balancer.markFailed(up.URL) - lastErr = err - continue - } - if t.failover[resp.StatusCode] { - t.balancer.markFailed(up.URL) - drainAndClose(resp.Body) - lastErr = core.E("upstream", core.Sprintf("upstream %s returned %d", up.URL, resp.StatusCode), nil) - continue - } - return resp, nil - } - - if lastErr != nil { - // Detail goes to the error (logged by ErrorHandler); the client sees a - // generic envelope so upstream URLs never leak. - return nil, &routerError{status: http.StatusServiceUnavailable, code: errCodeUpstreamUnavailable, message: "no healthy upstream available", cause: lastErr} - } - return nil, &routerError{status: http.StatusServiceUnavailable, code: errCodeUpstreamUnavailable, message: "all upstreams cooling"} -} - -// applyUpstream rewrites the outbound request to target the chosen upstream. -// A base path on the upstream URL is prefixed to the incoming request path. -func applyUpstream(out *http.Request, target *url.URL) { - out.URL.Scheme = target.Scheme - out.URL.Host = target.Host - out.Host = target.Host - if base := trimTrailingSlashes(target.Path); base != "" { - out.URL.Path = base + out.URL.Path - if out.URL.RawPath != "" { - out.URL.RawPath = base + out.URL.RawPath - } - } -} - -func drainAndClose(body interface{ Close() error }) { - if body != nil { - _ = body.Close() - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** (after Task 4 lands the shared symbols) - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamTransport -race` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -cd /Users/snider/Code/core/api -git add go/upstream_transport.go go/upstream_transport_internal_test.go -git commit -m "$(printf 'feat(api): upstreamTransport — selection + passive failover RoundTripper\n\nCo-Authored-By: Virgil ')" -``` - ---- - -## Task 4: Router config, options, default selector, engine wiring - -**Files:** -- Create: `go/upstream_router.go` -- Modify: `go/options.go` (add `WithUpstreamRouter` + option helpers) -- Modify: `go/api.go` (add `upstreamRouter` field; mount in `build()`) - -- [ ] **Step 1: Write the implementation file (`upstream_router.go`)** - -Create `go/upstream_router.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api - -import ( - "bytes" - "context" - "encoding/json" - "io" - "log/slog" - "net/http" - "net/http/httputil" // Note: AX-6 — reverse-proxy mechanics are structural; no core primitive. - "net/url" // Note: AX-6 — url.Parse is structural for the Rewrite placeholder target. - "strconv" - "time" - - core "dappco.re/go" - - "github.com/gin-gonic/gin" -) - -const ( - defaultUpstreamRouterPath = "/v1/chat/completions" - defaultUpstreamCooldown = 10 * time.Second - - errCodeInvalidRequest = "invalid_request" - errCodeInvalidRequestBody = "invalid_request_body" - errCodeRoutingRejected = "routing_rejected" - errCodeNoUpstream = "no_upstream_for_key" - errCodeRequestTooLarge = "request_too_large" - errCodeUpstreamUnavailable = "upstream_unavailable" - errCodeInvalidUpstreamResp = "invalid_upstream_response" -) - -type ctxKey int - -const ( - poolCtxKey ctxKey = iota - keyCtxKey - ginCtxKey -) - -// Selector resolves the routing key from the request. body holds the (bounded) -// request body, already read by the handler; it may be empty for bodyless requests. -type Selector func(c *gin.Context, body []byte) (key string, err error) - -// RouteFunc inspects the payload after the selector and may override the key or -// reject the request. Returning the same key is a no-op; a non-nil error aborts. -type RouteFunc func(c *gin.Context, key string, body []byte) (newKey string, err error) - -// UpstreamRouterOption configures a router built by WithUpstreamRouter. -type UpstreamRouterOption func(*upstreamRouterConfig) - -type upstreamRouterConfig struct { - registry *UpstreamRegistry - selector Selector - hook RouteFunc - paths []string - inRaw []any - outRaw []any - in []compiledTransformer - out []compiledTransformer - maxAttempts int - cooldown time.Duration - failover map[int]bool - transport http.RoundTripper -} - -// routerError carries an HTTP status + envelope code from the transport or -// ModifyResponse to the ReverseProxy ErrorHandler. -type routerError struct { - status int - code string - message string - cause error -} - -func (e *routerError) Error() string { - if e.cause != nil { - return e.message + ": " + e.cause.Error() - } - return e.message -} - -func (e *routerError) Unwrap() error { return e.cause } - -// WithSelector overrides the routing-key selector. Default: defaultModelSelector. -func WithSelector(fn Selector) UpstreamRouterOption { - return func(cfg *upstreamRouterConfig) { cfg.selector = fn } -} - -// WithRouteHook installs a decision hook to inspect the payload and override/reject. -func WithRouteHook(fn RouteFunc) UpstreamRouterOption { - return func(cfg *upstreamRouterConfig) { cfg.hook = fn } -} - -// WithRouterPaths sets the mounted paths (default ["/v1/chat/completions"]). -// Each path forwards its own path + query to the chosen upstream. -func WithRouterPaths(paths ...string) UpstreamRouterOption { - return func(cfg *upstreamRouterConfig) { cfg.paths = paths } -} - -// WithUpstreamTransformerIn adds request-body transformers (reuses the existing -// TransformerIn machinery; FieldRenamer etc. work). Operates on the raw body. -func WithUpstreamTransformerIn(t ...any) UpstreamRouterOption { - return func(cfg *upstreamRouterConfig) { cfg.inRaw = append(cfg.inRaw, t...) } -} - -// WithUpstreamTransformerOut adds response-body transformers, applied only to -// buffered (non-streaming) responses, on the raw upstream body. -func WithUpstreamTransformerOut(t ...any) UpstreamRouterOption { - return func(cfg *upstreamRouterConfig) { cfg.outRaw = append(cfg.outRaw, t...) } -} - -// WithFailover sets the max upstream attempts (default len(pool), each tried once) -// and the cooldown applied to a failed upstream (default 10s). -func WithFailover(maxAttempts int, cooldown time.Duration) UpstreamRouterOption { - return func(cfg *upstreamRouterConfig) { - cfg.maxAttempts = maxAttempts - if cooldown > 0 { - cfg.cooldown = cooldown - } - } -} - -// WithFailoverStatuses overrides which response statuses trigger failover -// (default: all >= 500). Pass e.g. 429 to also fail over on rate-limit responses. -func WithFailoverStatuses(statuses ...int) UpstreamRouterOption { - return func(cfg *upstreamRouterConfig) { - cfg.failover = map[int]bool{} - for _, s := range statuses { - cfg.failover[s] = true - } - } -} - -// WithUpstreamTransport sets the base RoundTripper used for dispatch (custom TLS, -// timeouts). Default: a clone of http.DefaultTransport. -func WithUpstreamTransport(rt http.RoundTripper) UpstreamRouterOption { - return func(cfg *upstreamRouterConfig) { cfg.transport = rt } -} - -// defaultFailoverStatuses returns the default failover status set: all >= 500. -func defaultFailoverStatuses() map[int]bool { - m := map[int]bool{} - for s := 500; s <= 599; s++ { - m[s] = true - } - return m -} - -// defaultModelSelector reads the OpenAI-style "model" field from a JSON body. -func defaultModelSelector(_ *gin.Context, body []byte) (string, error) { - var probe struct { - Model string `json:"model"` - } - if res := core.JSONUnmarshal(body, &probe); !res.OK { - return "", core.E("upstream.selector", "request body is not valid JSON", nil) - } - if core.Trim(probe.Model) == "" { - return "", core.E("upstream.selector", "request body has no \"model\" field", nil) - } - return probe.Model, nil -} - -func poolFromContext(ctx context.Context) ([]Upstream, bool) { - pool, ok := ctx.Value(poolCtxKey).([]Upstream) - return pool, ok -} - -func keyFromContext(ctx context.Context) (string, bool) { - key, ok := ctx.Value(keyCtxKey).(string) - return key, ok -} - -// finalise resolves defaults and compiles transformer pipelines. Returns an -// error if a transformer fails to compile. -func (cfg *upstreamRouterConfig) finalise() error { - if cfg.selector == nil { - cfg.selector = defaultModelSelector - } - if len(cfg.paths) == 0 { - cfg.paths = []string{defaultUpstreamRouterPath} - } - if cfg.cooldown <= 0 { - cfg.cooldown = defaultUpstreamCooldown - } - if cfg.failover == nil { - cfg.failover = defaultFailoverStatuses() - } - if cfg.transport == nil { - cfg.transport = http.DefaultTransport - } - in, err := compileTransformerPipeline(transformerDirectionIn, cfg.inRaw) - if err != nil { - return err - } - out, err := compileTransformerPipeline(transformerDirectionOut, cfg.outRaw) - if err != nil { - return err - } - cfg.in, cfg.out = in, out - return nil -} - -// buildProxy constructs the shared ReverseProxy for the router. -func (cfg *upstreamRouterConfig) buildProxy() *httputil.ReverseProxy { - balancer := newUpstreamBalancer(cfg.cooldown, time.Now) - transport := &upstreamTransport{ - base: cfg.transport, - balancer: balancer, - maxAttempts: cfg.maxAttempts, - failover: cfg.failover, - } - return &httputil.ReverseProxy{ - Transport: transport, - FlushInterval: -1, // stream SSE / chunked responses through immediately - Rewrite: func(pr *httputil.ProxyRequest) { - // Placeholder target so the pipeline has a valid URL; the transport - // overrides scheme/host/path per attempt for the selected upstream. - if pool, ok := poolFromContext(pr.In.Context()); ok && len(pool) > 0 { - if target, err := url.Parse(pool[0].URL); err == nil { - pr.Out.URL.Scheme = target.Scheme - pr.Out.URL.Host = target.Host - } - } - pr.SetXForwarded() - }, - ModifyResponse: cfg.modifyResponse, - ErrorHandler: cfg.errorHandler, - } -} - -func (cfg *upstreamRouterConfig) modifyResponse(resp *http.Response) error { - if len(cfg.out) == 0 { - return nil - } - if isEventStream(resp.Header.Get("Content-Type")) { - return nil // streaming: pass through untransformed - } - body, err := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if err != nil { - return &routerError{status: http.StatusBadGateway, code: errCodeInvalidUpstreamResp, message: "could not read upstream response", cause: err} - } - c, _ := resp.Request.Context().Value(ginCtxKey).(*gin.Context) - transformed, err := runTransformerPipeline(c, body, cfg.out) - if err != nil { - return &routerError{status: http.StatusBadGateway, code: errCodeInvalidUpstreamResp, message: "response transform failed", cause: err} - } - resp.Body = io.NopCloser(bytes.NewReader(transformed)) - resp.ContentLength = int64(len(transformed)) - resp.Header.Set("Content-Length", strconv.Itoa(len(transformed))) - return nil -} - -func (cfg *upstreamRouterConfig) errorHandler(w http.ResponseWriter, _ *http.Request, err error) { - re := &routerError{status: http.StatusBadGateway, code: errCodeUpstreamUnavailable, message: "upstream request failed"} - var got *routerError - if core.As(err, &got) { - re = got - } - slog.Warn("upstream router dispatch failed", "code", re.code, "err", err.Error()) - w.Header().Set("Content-Type", "application/json") - if re.status == http.StatusServiceUnavailable { - w.Header().Set("Retry-After", strconv.Itoa(int(cfg.cooldown.Seconds()))) - } - w.WriteHeader(re.status) - _ = json.NewEncoder(w).Encode(Fail(re.code, re.message)) -} - -// handler returns the gin.HandlerFunc mounted at each router path. -func (cfg *upstreamRouterConfig) handler(proxy *httputil.ReverseProxy) gin.HandlerFunc { - return func(c *gin.Context) { - body, ok := readUpstreamBody(c) - if !ok { - return - } - - key, err := cfg.selector(c, body) - if err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, Fail(errCodeInvalidRequest, err.Error())) - return - } - if cfg.hook != nil { - newKey, herr := cfg.hook(c, key, body) - if herr != nil { - c.AbortWithStatusJSON(http.StatusForbidden, Fail(errCodeRoutingRejected, herr.Error())) - return - } - if core.Trim(newKey) != "" { - key = newKey - } - } - - if len(cfg.in) > 0 { - body, err = runTransformerPipeline(c, body, cfg.in) - if err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, Fail(errCodeInvalidRequestBody, err.Error())) - return - } - } - - pool, ok := cfg.registry.resolve(key) - if !ok { - c.AbortWithStatusJSON(http.StatusNotFound, Fail(errCodeNoUpstream, "no upstream registered for key: "+key)) - return - } - - bound := body // capture for GetBody closure - c.Request.Body = io.NopCloser(bytes.NewReader(bound)) - c.Request.ContentLength = int64(len(bound)) - c.Request.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(bound)), nil } - - ctx := context.WithValue(c.Request.Context(), poolCtxKey, pool) - ctx = context.WithValue(ctx, keyCtxKey, key) - ctx = context.WithValue(ctx, ginCtxKey, c) - c.Request = c.Request.WithContext(ctx) - - proxy.ServeHTTP(upstreamResponseWriter(c), c.Request) - } -} - -// upstreamResponseWriter unwraps gin's ResponseWriter to the underlying -// http.ResponseWriter, which httputil.ReverseProxy requires for flush/cancel. -func upstreamResponseWriter(c *gin.Context) http.ResponseWriter { - var w http.ResponseWriter = c.Writer - if uw, ok := w.(interface{ Unwrap() http.ResponseWriter }); ok { - w = uw.Unwrap() - } - return w -} - -func readUpstreamBody(c *gin.Context) ([]byte, bool) { - limited := http.MaxBytesReader(c.Writer, c.Request.Body, maxToolRequestBodyBytes) - body, err := io.ReadAll(limited) - if err != nil { - if err.Error() == "http: request body too large" { - c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, Fail(errCodeRequestTooLarge, "Request body exceeds the maximum allowed size")) - return nil, false - } - c.AbortWithStatusJSON(http.StatusBadRequest, Fail(errCodeInvalidRequest, "Unable to read request body")) - return nil, false - } - return body, true -} - -func isEventStream(contentType string) bool { - return core.HasPrefix(core.Lower(core.Trim(contentType)), "text/event-stream") -} -``` - -> The Rewrite target is only a placeholder to satisfy `httputil.ReverseProxy` (which requires a non-nil `Rewrite`/`Director`); `upstreamTransport.RoundTrip` overrides scheme/host/path per attempt for the actually-selected upstream, so `pool[0]` here is never the real dispatch target. - -- [ ] **Step 2: Add the engine field and mount (modify `api.go`)** - -In `go/api.go`, add the field to the `Engine` struct (after `noRouteHandler gin.HandlerFunc` at line ~115): - -```go - // upstreamRouter, when set via WithUpstreamRouter, mounts a selector-keyed - // reverse proxy over a pool of HTTP upstreams at the configured paths. - upstreamRouter *upstreamRouterConfig -``` - -In `go/api.go` `build()`, after the chat-completions mount block (line ~443) add: - -```go - // Mount the selector-keyed upstream router when configured. - if e.upstreamRouter != nil { - proxy := e.upstreamRouter.buildProxy() - h := e.upstreamRouter.handler(proxy) - for _, p := range e.upstreamRouter.paths { - r.Any(p, h) - } - } -``` - -- [ ] **Step 3: Add `WithUpstreamRouter` (modify `options.go`)** - -In `go/options.go`, after `WithChatCompletionsPath` (line ~849) add: - -```go -// WithUpstreamRouter mounts a selector-keyed reverse proxy that load-balances -// each request across a runtime-mutable pool of HTTP upstreams (weighted -// round-robin + passive failover, hybrid streaming, decision hook, transformer -// composition). The registry is the source of truth for upstreams. -// -// Example: -// -// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) -// _ = reg.Set("lemma", api.Upstream{URL: "http://127.0.0.1:11434"}) -// engine, _ := api.New(api.WithUpstreamRouter(reg)) -func WithUpstreamRouter(reg *UpstreamRegistry, opts ...UpstreamRouterOption) Option { - return func(e *Engine) { - if reg == nil { - return - } - cfg := &upstreamRouterConfig{registry: reg} - for _, opt := range opts { - if opt != nil { - opt(cfg) - } - } - if err := cfg.finalise(); err != nil { - // Transformer compile errors mirror the panic contract used by - // transformerRouteConfigForDescription (transformer_in.go:78). - panic(err) - } - e.upstreamRouter = cfg - } -} -``` - -- [ ] **Step 4: Build and run all prior unit suites together** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go build ./ && GOWORK=off go test ./ -run 'TestUpstream' -race` -Expected: PASS for `TestUpstreamRegistry*`, `TestUpstreamBalancer*`, `TestUpstreamTransport*` (Task 3's tests now compile and pass). - -- [ ] **Step 5: Commit** - -```bash -cd /Users/snider/Code/core/api -git add go/upstream_router.go go/options.go go/api.go go/upstream_transport_internal_test.go -git commit -m "$(printf 'feat(api): WithUpstreamRouter — config, options, default model selector, engine mount\n\nCo-Authored-By: Virgil ')" -``` - ---- - -## Task 5: Integration tests (httptest end-to-end) - -**Files:** -- Create: `go/upstream_router_test.go` - -- [ ] **Step 1: Write the failing integration tests** - -Create `go/upstream_router_test.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api_test - -import ( - "bufio" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - api "dappco.re/go/api" - "github.com/gin-gonic/gin" -) - -// newEngine builds a test engine with the router mounted, returning a live server. -func serve(t *testing.T, reg *api.UpstreamRegistry, opts ...api.UpstreamRouterOption) *httptest.Server { - t.Helper() - e, err := api.New(api.WithUpstreamRouter(reg, opts...)) - if err != nil { - t.Fatalf("New: %v", err) - } - return httptest.NewServer(e.Handler()) -} - -func post(t *testing.T, base, path, body string) *http.Response { - t.Helper() - resp, err := http.Post(base+path, "application/json", strings.NewReader(body)) - if err != nil { - t.Fatalf("POST %s: %v", path, err) - } - return resp -} - -func TestUpstreamRouter_RoutesByModel_Good(t *testing.T) { - upA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, `{"upstream":"A"}`) - })) - defer upA.Close() - - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - if err := reg.Set("lemma", api.Upstream{URL: upA.URL}); err != nil { - t.Fatalf("Set: %v", err) - } - srv := serve(t, reg) - defer srv.Close() - - resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"lemma"}`) - defer resp.Body.Close() - got, _ := io.ReadAll(resp.Body) - if !strings.Contains(string(got), `"upstream":"A"`) { - t.Fatalf("body = %s, want routed to A", got) - } -} - -func TestUpstreamRouter_MissingModel_Bad(t *testing.T) { - reg := api.NewUpstreamRegistry() - _ = reg.SetDefault(api.Upstream{URL: "https://example.com"}) - srv := serve(t, reg) - defer srv.Close() - - resp := post(t, srv.URL, "/v1/chat/completions", `{}`) - defer resp.Body.Close() - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", resp.StatusCode) - } -} - -func TestUpstreamRouter_Failover_Good(t *testing.T) { - dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer dead.Close() - live := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, `{"ok":true}`) - })) - defer live.Close() - - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - if err := reg.Set("m", api.Upstream{URL: dead.URL}, api.Upstream{URL: live.URL}); err != nil { - t.Fatalf("Set: %v", err) - } - srv := serve(t, reg) - defer srv.Close() - - resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`) - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, want 200 (failed over to live)", resp.StatusCode) - } -} - -func TestUpstreamRouter_AllDown_503_Ugly(t *testing.T) { - dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadGateway) - })) - defer dead.Close() - - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.Set("m", api.Upstream{URL: dead.URL}) - srv := serve(t, reg) - defer srv.Close() - - resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`) - defer resp.Body.Close() - got, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusServiceUnavailable { - t.Fatalf("status = %d, want 503", resp.StatusCode) - } - if resp.Header.Get("Retry-After") == "" { - t.Error("missing Retry-After header on 503") - } - if strings.Contains(string(got), dead.URL) { - t.Error("upstream URL leaked into client response body") - } -} - -func TestUpstreamRouter_StreamingPassthrough_Good(t *testing.T) { - up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - f, _ := w.(http.Flusher) - for _, chunk := range []string{"data: a\n\n", "data: b\n\n", "data: [DONE]\n\n"} { - _, _ = io.WriteString(w, chunk) - if f != nil { - f.Flush() - } - } - })) - defer up.Close() - - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.Set("m", api.Upstream{URL: up.URL}) - // Out transformer present to prove it is NOT applied to streams. - srv := serve(t, reg, api.WithUpstreamTransformerOut(api.RenameFields(map[string]string{"x": "y"}))) - defer srv.Close() - - resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`) - defer resp.Body.Close() - if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { - t.Fatalf("Content-Type = %q, want text/event-stream", ct) - } - sc := bufio.NewScanner(resp.Body) - var lines int - for sc.Scan() { - if strings.HasPrefix(sc.Text(), "data:") { - lines++ - } - } - if lines != 3 { - t.Fatalf("got %d data lines, want 3 (stream byte-preserved)", lines) - } -} - -func TestUpstreamRouter_TransformInOut_Good(t *testing.T) { - var gotBody string - up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, _ := io.ReadAll(r.Body) - gotBody = string(b) - _, _ = io.WriteString(w, `{"internal_id":42}`) - })) - defer up.Close() - - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.Set("m", api.Upstream{URL: up.URL}) - srv := serve(t, reg, - api.WithUpstreamTransformerIn(api.RenameFields(map[string]string{"q": "prompt"})), - api.WithUpstreamTransformerOut(api.RenameFields(map[string]string{"internal_id": "id"})), - ) - defer srv.Close() - - // Selector reads "model" from the original body; the in-transform then renames - // q->prompt before dispatch so the upstream sees the translated shape. - resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m","q":"hello"}`) - defer resp.Body.Close() - out, _ := io.ReadAll(resp.Body) - if !strings.Contains(gotBody, `"prompt"`) { - t.Errorf("upstream body = %s, want renamed q->prompt", gotBody) - } - if !strings.Contains(string(out), `"id":42`) { - t.Errorf("client body = %s, want renamed internal_id->id", out) - } -} - -func TestUpstreamRouter_RouteHookOverride_Good(t *testing.T) { - upB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, `{"pool":"B"}`) - })) - defer upB.Close() - - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.Set("b", api.Upstream{URL: upB.URL}) - srv := serve(t, reg, api.WithRouteHook(func(_ *gin.Context, _ string, _ []byte) (string, error) { - return "b", nil - })) - defer srv.Close() - - resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"anything"}`) - defer resp.Body.Close() - got, _ := io.ReadAll(resp.Body) - if !strings.Contains(string(got), `"pool":"B"`) { - t.Fatalf("body = %s, want hook-overridden pool B", got) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail then pass** - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamRouter -race` -Expected: after fixing the `*gin.Context` import note, PASS for all seven integration tests. - -- [ ] **Step 3: SSRF-posture integration assertion** - -Add to `go/upstream_router_test.go`: - -```go -func TestUpstreamRouter_SSRFPosture_Bad(t *testing.T) { - reg := api.NewUpstreamRegistry() // no allow-list - if err := reg.Set("m", api.Upstream{URL: "http://127.0.0.1:11434"}); err == nil { - t.Fatal("loopback accepted without AllowPrivateUpstreams, want rejection") - } -} - -func TestUpstreamRouter_Composition_Middleware_Good(t *testing.T) { - up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, `{"ok":true}`) - })) - defer up.Close() - - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8")) - _ = reg.SetDefault(api.Upstream{URL: up.URL}) - // WithSunset adds a Sunset header to every response via engine middleware. - e, _ := api.New(api.WithSunset("2026-12-31", "https://api.example.com/v2"), api.WithUpstreamRouter(reg)) - srv := httptest.NewServer(e.Handler()) - defer srv.Close() - - resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`) - defer resp.Body.Close() - if resp.Header.Get("Sunset") == "" { - t.Fatal("Sunset header absent — engine middleware did not wrap the mounted router") - } -} -``` - -> Uses `WithSunset` (deterministic per-response header) rather than auth to prove engine middleware wraps the mounted router — the API's bearer middleware is permissive, so a missing token does not reliably 401. Confirm the exact header name is `Sunset` (RFC 8594; see `sunset.go`). - -Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamRouter -race` -Expected: PASS (all integration tests including SSRF + composition). - -- [ ] **Step 4: Write the example test (godoc-facing)** - -Create `go/upstream_router_example_test.go`: - -```go -// SPDX-License-Identifier: EUPL-1.2 - -package api_test - -import ( - "fmt" - - api "dappco.re/go/api" -) - -func ExampleWithUpstreamRouter() { - reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8", "10.0.0.0/8")) - _ = reg.Set("lemma", - api.Upstream{URL: "http://10.0.0.5:8000", Weight: 2}, - api.Upstream{URL: "http://10.0.0.6:8000", Weight: 1}, - ) - _ = reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}) - - engine, err := api.New(api.WithUpstreamRouter(reg)) - if err != nil { - panic(err) - } - fmt.Println(engine.Addr()) - // Output: :8080 -} -``` - -- [ ] **Step 5: Commit** - -```bash -cd /Users/snider/Code/core/api -git add go/upstream_router_test.go go/upstream_router_example_test.go -git commit -m "$(printf 'test(api): upstream router integration — routing, failover, streaming, transforms, SSRF, composition\n\nCo-Authored-By: Virgil ')" -``` - ---- - -## Task 6: Full QA gate - -**Files:** none (verification only) - -- [ ] **Step 1: Format + vet + full test with race** - -Run: -```bash -cd /Users/snider/Code/core/api/go -gofmt -l upstream_registry.go upstream_balancer.go upstream_transport.go upstream_router.go -GOWORK=off go vet ./ -GOWORK=off go test ./ -race -count=1 -``` -Expected: `gofmt -l` prints nothing (all formatted); `vet` clean; all tests PASS. - -- [ ] **Step 2: Lint + security audit (matches repo `core go qa full`)** - -Run: -```bash -cd /Users/snider/Code/core/api/go -GOWORK=off go test ./ -run 'Example' -count=1 # godoc examples compile + match Output -golangci-lint run ./ 2>/dev/null || echo "run golangci-lint if available" -gosec -quiet ./ 2>/dev/null || echo "run gosec if available" -``` -Expected: example output matches; lint clean; `gosec` reports only the annotated `#nosec G107` on `upstreamTransport.RoundTrip` (justified — registration-validated operator upstreams). - -- [ ] **Step 3: Confirm the gateway binary still builds** - -Run: `cd /Users/snider/Code/core/api/go && go build ./cmd/gateway/ && GOWORK=off go build ./...` -Expected: exit 0 (no regression to existing build). - -- [ ] **Step 4: Commit any formatting/lint fixes** - -```bash -cd /Users/snider/Code/core/api -git add -A go/ -git commit -m "$(printf 'chore(api): gofmt + lint pass for upstream router\n\nCo-Authored-By: Virgil ')" || echo "nothing to commit" -``` - ---- - -## Spec coverage check - -| Spec section | Task | -|---|---| -| §4 `WithUpstreamRouter`, `Upstream`, `UpstreamRegistry`, `Selector`, `RouteFunc`, options | Tasks 1, 4 | -| §4 `AllowPrivateUpstreams` registry option | Task 1 | -| §5 `UpstreamRegistry` / `upstreamBalancer` / `upstreamTransport` / handler units | Tasks 1, 2, 3, 4 | -| §6 data flow (body→selector→hook→transformIn→pool→proxy→transport→response) | Tasks 4, 5 | -| §7 error taxonomy (400/403/404/413/502/503 + passthrough) | Tasks 4, 5 | -| §8 SSRF block-by-default + opt-in + `#nosec` + no URL leak | Tasks 1, 3, 5 | -| §9 testing matrix (Good/Bad/Ugly, weighted spread, cooldown, streaming, transforms, composition) | Tasks 1–5 | -| §10 file layout | all | - -**Deferred to future extensions (spec §11), not in this plan:** sticky/consistent-hash, active health checks, direct-upstream hook return, per-chunk stream transforms, per-pool rate limits. diff --git a/docs/superpowers/specs/2026-06-06-chat-completions-remote-backend-design.md b/docs/superpowers/specs/2026-06-06-chat-completions-remote-backend-design.md deleted file mode 100644 index 49db605..0000000 --- a/docs/superpowers/specs/2026-06-06-chat-completions-remote-backend-design.md +++ /dev/null @@ -1,257 +0,0 @@ -# Chat Completions — Remote Backend + Format Adapters — Design - -- **Date:** 2026-06-06 -- **Status:** Design — approved, pending implementation plan -- **Module:** `dappco.re/go/api` (`core/api/go`) -- **Author:** Snider + Cladius (brainstorming) -- **Builds on:** `WithUpstreamRouter` (`docs/superpowers/specs/2026-06-06-upstream-router-design.md`) — reuses `UpstreamRegistry`, `upstreamBalancer`, `upstreamTransport` unchanged. -- **Related:** `RFC.md` §11 (chat completions), `RFC.providers.md` Open Question 4 (go-ai backend) + §7.3 (PHP-direct anti-pattern). - ---- - -## 1. Context & Problem - -`RFC.md` §11 specifies an OpenAI-compatible `POST /v1/chat/completions`. Today `WithChatCompletions(resolver *ModelResolver)` resolves a model name to a **local, in-process** `inference.TextModel` (`chat_completions.go:714`) and is **loopback-only** (`:693`). There is no way for that endpoint to serve a model hosted on a **remote** OpenAI-compatible server (Ollama, LiteLLM, vLLM) or a non-OpenAI server (Ollama-native, Anthropic). - -`RFC.providers.md` Open Question 4 — *"does go-ai proxy to Ollama / LiteLLM, run in-process, or hybrid?"* — is flagged there as the highest-leverage architectural decision. This feature answers it: **hybrid**. Local models are served in-process; everything else is routed to a remote pool via the already-built upstream router, with per-model format adapters for non-OpenAI backends. - -This also enables the fix for the `RFC.providers.md` §7.3 anti-pattern (PHP calling external model services directly): one stable Go endpoint fronts heterogeneous backends. - -## 2. Goals / Non-Goals - -**Goals** -- One `/v1/chat/completions` endpoint that serves **local in-process** models and **remote** models, decided per request by model name. -- Reuse the upstream router's `UpstreamRegistry` + weighted-RR + passive-failover transport for the remote path. -- **Passthrough by default** for OpenAI-compatible upstreams (verbatim request + response, preserving fields our struct doesn't model). -- **Per-model format adapters** for non-OpenAI upstreams: request mapping, non-streaming response mapping, and **per-chunk streaming transcoding**. Built-ins: Ollama-native, Anthropic. -- Opt-in to expose the endpoint off-loopback, gated by a configured bearer. - -**Non-Goals (v1)** -- A generic/pluggable streaming-transcoder framework beyond the two built-in adapters (consumers can implement `ChatFormatAdapter` themselves, but only Ollama + Anthropic ship). -- Tool/function-calling translation across formats (passthrough preserves OpenAI `tools`; adapter tool-mapping is a future extension). -- Embeddings/scoring endpoints (separate go-ai provider work, `RFC.providers.md` §4.1). -- Changing the local inference path (`serveStreaming`/`serveNonStreaming`) — reused unchanged. - -## 3. Settled Decisions - -| Fork | Decision | -|------|----------| -| Dispatch precedence | **Local-first** (`resolver.Knows(model)`) → else **remote** (per-model pool or `SetDefault`) → else 404 | -| Bind posture | **Configurable opt-in** (`WithChatCompletionsAllowRemoteClients`), allowed off-loopback only when a bearer is configured | -| Translation | **Per-pool adapters**: passthrough default; Ollama + Anthropic built-ins with request + non-stream + **streaming** transcoding | -| Proxy core | **Reuse `upstreamBalancer`+`upstreamTransport` directly** (not `httputil.ReverseProxy`) — ReverseProxy can't rewrite request bodies per-format or stream-transcode | -| Scope | One spec; internal unit boundaries kept crisp (dispatcher / passthrough / adapter iface / Ollama / Anthropic / bind) | - -## 4. Public Surface - -```go -// WithChatCompletionsRemote attaches a remote backend to /v1/chat/completions. -// Use WITH WithChatCompletions for hybrid (local-first); ALONE for remote-only. -// -// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("10.0.0.0/8")) -// _ = reg.Set("claude-3-opus", api.Upstream{URL: "https://anthropic-gw.lthn.sh"}) -// _ = reg.Set("llama3:70b", api.Upstream{URL: "http://gpu1:11434"}, api.Upstream{URL: "http://gpu2:11434"}) -// _ = reg.SetDefault(api.Upstream{URL: "https://llm.lthn.sh"}) // OpenAI-compatible — passthrough -// engine, _ := api.New( -// api.WithChatCompletions(localResolver), // local-first (optional) -// api.WithChatCompletionsRemote(reg, -// api.WithChatModelAdapter("llama3:70b", api.OllamaAdapter()), -// api.WithChatModelAdapter("claude-3-opus", api.AnthropicAdapter()), -// ), -// ) -func WithChatCompletionsRemote(reg *UpstreamRegistry, opts ...ChatRemoteOption) Option - -type ChatRemoteOption func(*chatRemoteConfig) -func WithChatModelAdapter(model string, a ChatFormatAdapter) ChatRemoteOption // non-OpenAI models only -func WithChatRemoteFailover(maxAttempts int, cooldown time.Duration) ChatRemoteOption -func WithChatRemoteTransport(rt http.RoundTripper) ChatRemoteOption - -// WithChatCompletionsAllowRemoteClients permits non-loopback clients, but only -// when a bearer is configured (WithBearerAuth) — mirrors the engine's -// ErrPublicBindNoBearer invariant. Without it, the endpoint stays loopback-only. -func WithChatCompletionsAllowRemoteClients() Option - -// ChatFormatAdapter maps between the OpenAI chat shape and a non-OpenAI upstream. -// Passthrough (OpenAI-compatible) upstreams need NO adapter — that is the default. -type ChatFormatAdapter interface { - Name() string // "ollama", "anthropic" - UpstreamPath() string // "/api/chat", "/v1/messages" - // BuildRequest maps the OpenAI request into the upstream body + protocol headers - // (Content-Type, anthropic-version). Operator secrets (x-api-key) live in Upstream.Headers. - BuildRequest(req ChatCompletionRequest) (body []byte, headers map[string]string, err error) - // DecodeResponse maps a complete (non-streaming) upstream body into the OpenAI response. - DecodeResponse(model string, upstream []byte) (ChatCompletionResponse, error) - // Transcoder converts the upstream stream into OpenAI chunk SSE. nil = non-stream only. - Transcoder() ChatStreamTranscoder -} - -// ChatStreamTranscoder converts an upstream response stream into OpenAI -// chat.completion.chunk SSE events written to w (flushing as it goes); it emits -// the terminating "data: [DONE]". Returns on upstream EOF or ctx cancellation. -type ChatStreamTranscoder interface { - Transcode(w io.Writer, flush func(), upstream io.Reader, meta ChatStreamMeta) error -} -type ChatStreamMeta struct { - ID string - Model string - Created int64 -} - -// Built-in adapters (only the non-OpenAI formats need one). -func OllamaAdapter() ChatFormatAdapter // OpenAI ⇄ Ollama-native /api/chat (NDJSON stream) -func AnthropicAdapter() ChatFormatAdapter // OpenAI ⇄ Anthropic /v1/messages (event-stream) -``` - -**Contract rules** -- **Passthrough is the default; adapters are per-model exceptions.** A model with no `WithChatModelAdapter` is forwarded verbatim (raw request bytes up, raw response bytes down), preserving fields the `ChatCompletionRequest`/`Response` structs don't model (`tools`, `response_format`, `logprobs`, …). -- **Composable**: `WithChatCompletions` (local) and `WithChatCompletionsRemote` (remote) each set an Engine field; `build()` mounts one handler holding `resolver` (optional) + `remote` (optional). Local-only, remote-only, and hybrid all fall out. -- **`WithChatModelAdapter` keys by model** (the registry key); the adapter owns the upstream path + both-direction mapping + protocol headers. -- Remote failover/transport config reuses the router's machinery; defaults: `maxAttempts = len(pool)`, `cooldown = 10s`, base transport = cloned `http.DefaultTransport`. - -## 5. Dispatch Flow - -``` -ServeHTTP(c): - 1. not-configured: resolver==nil && remote==nil → 503 service_unavailable - 2. bind guard: loopback → OK; non-loopback → OK only if allowRemote && bearerConfigured, else 403 - 3. decode body → req; KEEP raw bytes; invalid → 400 invalid_request_error (param body) - 4. validate(req) (existing); invalid → mapped 400 - 5. LOCAL: - - PURE-LOCAL (remote == nil): model := resolver.ResolveModel(req.Model) directly - → existing serveStreaming / serveNonStreaming. This is the CURRENT behaviour, - unchanged — no Knows() gate, so no risk of a loadable model 404ing. - - HYBRID (remote != nil): if resolver != nil && resolver.Knows(req.Model): - model := resolver.ResolveModel(req.Model) // load; err → mapResolverError - → existing serveStreaming / serveNonStreaming; return - else fall through to remote (avoids loading a remote-only model locally). - 6. REMOTE (remote != nil): pool, ok := remote.reg.resolve(req.Model); !ok → 404 model_not_found - adapter := remote.adapters[req.Model] // nil ⇒ passthrough - dispatchRemote(c, req, raw, pool, adapter); return - 7. else → 404 model_not_found - -Note: `resolve` returns the default pool (if `SetDefault` was called) for ANY unmatched -model, so with a default pool set the 404 in step 6 fires only when no default exists — -unknown models are proxied to the default upstream (which returns its own model_not_found). -`Knows()` MUST mirror `ResolveModel`'s resolution sources exactly (cache ∪ models.yaml ∪ -discovery) so a Knows()-false model is genuinely one ResolveModel couldn't serve. - -dispatchRemote(c, req, raw, pool, adapter): - a. build upstream request: - passthrough (adapter==nil): path "/v1/chat/completions", body = raw - adapter: path = adapter.UpstreamPath(); body, hdrs = adapter.BuildRequest(req) - outReq := POST(path, body); set GetBody (replay); apply hdrs - b. bind {pool, key} on ctx; resp, err := transport.RoundTrip(outReq) // weighted pick + failover (reused) - err (*routerError) → OpenAI error shape (503 upstream_unavailable + Retry-After / 502) - c. deliver: - upstream non-2xx: passthrough → copy status+body verbatim; adapter → wrap into OpenAI error shape - req.Stream: passthrough → SSE headers; flushing io.Copy(resp.Body → c.Writer) - adapter → tr := adapter.Transcoder(); tr==nil → 400 (param stream); - else SSE headers; tr.Transcode(c.Writer, flush, resp.Body, meta) - non-stream: passthrough → copy resp body verbatim (200, application/json) - adapter → out := adapter.DecodeResponse(model, body); err → 502; c.JSON(200, out) -``` - -### 5.1 `ModelResolver.Knows(name) bool` (new) - -`ResolveModel` *loads* the model (`inference.LoadModel`), so it cannot be used as a cheap local-vs-remote test — it would load a remote-only model locally. The spec adds a **no-load existence check**: - -```go -// Knows reports whether the resolver can serve name without loading it: a hit in -// the loaded-model cache, the models.yaml mapping, or the (cached) discovery set. -func (r *ModelResolver) Knows(name string) bool -``` - -Uses internals it already has (`loadedByName`, `modelsYAMLMapping`, `resolveDiscoveredPath`). Discovery results are cached so `Knows` stays cheap on the hot path. - -### 5.2 Delivery writer - -Streaming and buffered responses are written through gin's `c.Writer` (it implements `http.Flusher`) — never the unwrapped raw writer. This is the lesson carried from the upstream router: keeps gin's `Written()` tracking correct and avoids the superfluous-`WriteHeader` warning. The transcoder's `flush` callback is `c.Writer.Flush`. - -## 6. Format Adapters - -### 6.1 OllamaAdapter — OpenAI ⇄ Ollama-native `/api/chat` - -| Direction | Mapping | -|---|---| -| Request | `{model, messages:[{role,content}], stream, options:{temperature, top_p, top_k, num_predict←max_tokens, stop←stop}}`; headers `Content-Type: application/json`. NOTE: Ollama reads `stop` **inside `options`**, not at the top level. | -| Non-stream resp | Ollama `{message:{role,content}, done, done_reason, prompt_eval_count, eval_count}` → content=`message.content`; `usage{prompt_tokens←prompt_eval_count, completion_tokens←eval_count}`; finish_reason=`length` if `done_reason=="length"` else `stop` | -| Stream (NDJSON) | each line `{message:{content:}, done:false}` → OpenAI chunk `delta.content`; first chunk adds `delta.role:"assistant"`; final line `{done:true, done_reason, eval_count}` → final chunk `finish_reason`, then `data: [DONE]`. Flush per line. | - -### 6.2 AnthropicAdapter — OpenAI ⇄ Anthropic `/v1/messages` - -| Direction | Mapping | -|---|---| -| Request | OpenAI `role:"system"` messages → top-level `system`; rest → `messages:[{role,content}]`; `max_tokens` (mandatory — default if absent), `temperature, top_p, top_k, stop_sequences←stop, stream`; headers `anthropic-version: 2023-06-01`, `Content-Type: application/json` | -| Non-stream resp | `{content:[{type:"text",text}], stop_reason, usage:{input_tokens,output_tokens}}` → content=concat text blocks; `usage{prompt_tokens←input_tokens, completion_tokens←output_tokens}`; finish_reason=map(`end_turn`→stop, `max_tokens`→length, `stop_sequence`→stop) | -| Stream (event-stream) | parse named SSE events: `message_start` (seed id/usage), `content_block_delta`+`text_delta` → OpenAI `delta.content` (first adds `delta.role:"assistant"`), `message_delta` (capture `stop_reason`), `message_stop` → final chunk `finish_reason`, then `data: [DONE]`. Flush per delta. | - -Each adapter is an isolated unit (own file + tests). The Anthropic streaming transcoder is the fiddliest piece and gets the most adversarial coverage (fixture-driven). - -## 7. Bind Opt-in + Error Taxonomy - -**Bind.** The handler is constructed with `allowRemote` + `bearerConfigured` from the engine. Per-request guard: loopback always OK; non-loopback OK only if `allowRemote && bearerConfigured`, else 403. Mirrors `ErrPublicBindNoBearer` at the request layer. Documented caveat: `WithBearerAuth` is permissive, so operators must pair this with an auth-guarded route (`RequireAuth`) for true enforcement; the handler gate is the structural "don't expose local inference off-box without a configured bearer" guard. - -**Errors** — OpenAI shape (`{"error":{message,type,param,code}}`) via the existing `writeChatCompletionError`; upstream URLs never leak (details → logs). - -| Condition | HTTP | code | -|---|---|---| -| Not configured | 503 | service_unavailable | -| Non-loopback w/o allowRemote+bearer | 403 | — | -| Body decode / validation fail | 400 | (existing) | -| Local load error | mapped | `mapResolverError` (`model_not_found`/`model_loading`/`inference_error`) | -| Known neither locally nor remotely | 404 | `model_not_found` | -| `adapter.BuildRequest` fail | 500 | `inference_error` | -| All upstreams failed/cooling | 503 | `upstream_unavailable` + `Retry-After` | -| `adapter.DecodeResponse` fail | 502 | `invalid_upstream_response` | -| Stream requested, adapter non-streaming | 400 | — (param `stream`) | -| Upstream non-2xx, passthrough | verbatim | upstream's OpenAI-ish error copied through | -| Upstream non-2xx, adapter | mapped | upstream status/body wrapped into OpenAI error shape | - -## 8. Testing Strategy - -Reuses the router's tested `balancer`/`transport` (no re-test). Convention: `_Good/_Bad/_Ugly`, example test, `-race`, `GOWORK=off`. - -**Per-unit** -- `ModelResolver.Knows()` — `_Good`: cache / `models.yaml` / discovered hits → true **without loading** (sentinel resolver asserts no load); `_Bad`: unknown → false. -- Dispatcher (fake resolver + httptest remote): `Knows`-true → local; registered remote → proxied; default-pool → proxied; unknown → 404 `model_not_found`. -- OllamaAdapter — table-driven `BuildRequest` / `DecodeResponse`; `Transcoder` fed a captured NDJSON fixture → OpenAI chunks, role-on-first, finish_reason, `data: [DONE]`. -- AnthropicAdapter — `BuildRequest` (system extraction, mandatory `max_tokens` default, sampling, `anthropic-version`), `DecodeResponse` (text-block concat, `stop_reason` map, usage), `Transcoder` fed a captured event-stream fixture → OpenAI SSE + `[DONE]`. Most adversarial coverage. - -**Integration (`httptest` upstreams)** -- Hybrid: local-first model in-process + remote model proxied on one endpoint. -- Passthrough remote: request forwarded verbatim incl. an unmodelled field (`tools`) — fidelity; response verbatim; SSE passthrough. -- Ollama e2e: upstream speaking `/api/chat` (non-stream + NDJSON stream) → client gets OpenAI shape. -- Anthropic e2e: upstream speaking `/v1/messages` (non-stream + event-stream) → client gets OpenAI shape; `anthropic-version` sent. -- Failover (reuses transport): dead+live upstreams → fails over. -- Bind: non-loopback → 403 by default; with `WithChatCompletionsAllowRemoteClients`+`WithBearerAuth` → allowed; opt-in **without** bearer → still 403. -- Errors: unknown → 404 `model_not_found`; stream+non-streaming-adapter → 400; all-down → 503 `upstream_unavailable`+`Retry-After`+no-URL-leak; upstream 4xx passthrough verbatim. - -**Gates:** `GOWORK=off go test ./ -race`; vet; gofmt; gosec. - -## 9. File Layout - -``` -go/chat_remote.go chatRemoteConfig, WithChatCompletionsRemote + opts, dispatchRemote, bind opt-in (+ _test, _example_test) -go/chat_adapter.go ChatFormatAdapter / ChatStreamTranscoder / ChatStreamMeta -go/chat_adapter_ollama.go OllamaAdapter (+ _test, testdata NDJSON fixture) -go/chat_adapter_anthropic.go AnthropicAdapter (+ _test, testdata event-stream fixture) -go/chat_completions.go (mod) handler holds resolver?+remote?+allowRemote+bearerConfigured; bind guard; local-first dispatch; ModelResolver.Knows -go/options.go (mod) WithChatCompletionsRemote, WithChatModelAdapter, WithChatRemoteFailover, WithChatRemoteTransport, WithChatCompletionsAllowRemoteClients -go/api.go (mod) Engine fields (chatRemote *chatRemoteConfig, chatAllowRemote bool); build wiring -``` - -## 10. Future Extensions (out of v1) - -- Generic/pluggable streaming-transcoder registry beyond the two built-ins. -- Tool/function-calling translation across non-OpenAI formats. -- Additional adapters (Gemini, Cohere, …) implementing `ChatFormatAdapter`. -- Per-model rate limiting (ties to `RFC.md` §5 + go-ratelimit; shared with the router's deferred per-pool limits). -- Surfacing the remote/adapter routes in the generated OpenAPI spec (the broader describability gap). - -## 11. Open Implementation Notes - -- Confirm `ModelResolver` internals (`loadedByName`, `modelsYAMLMapping`, `resolveDiscoveredPath`) at implementation time and build `Knows` to reuse them with no load. -- Confirm `isLoopbackRequest`, `writeChatCompletionError`, `mapResolverError`, `ChatCompletionRequest/Response/Chunk`, `newChatCompletionID`, `NewThinkingExtractor` signatures (all in `chat_completions.go`) and reuse verbatim. -- Reuse `upstreamTransport` via its context-bound pool/key contract (`poolCtxKey`/`keyCtxKey`); construct the balancer+transport in `chatRemoteConfig.finalise()` mirroring the router's `buildProxy`. -- Capture small representative Ollama NDJSON and Anthropic event-stream samples as `testdata/` fixtures (or inline consts) for the transcoder tests. -- `BuildRequest` returning headers is a refinement of the interface beyond the router's transformer shape — keep operator secrets in `Upstream.Headers`, adapter contributes only protocol headers. diff --git a/docs/superpowers/specs/2026-06-06-openapi-inference-describability-design.md b/docs/superpowers/specs/2026-06-06-openapi-inference-describability-design.md deleted file mode 100644 index b245490..0000000 --- a/docs/superpowers/specs/2026-06-06-openapi-inference-describability-design.md +++ /dev/null @@ -1,105 +0,0 @@ -# OpenAPI Describability for the Inference Surface — Design - -- **Date:** 2026-06-06 -- **Status:** Design — approved, pending implementation plan -- **Module:** `dappco.re/go/api` (`core/api/go`) -- **Author:** Snider + Cladius (brainstorming) -- **Builds on:** `WithUpstreamRouter` + `WithChatCompletionsRemote` (the two prior specs in this directory). -- **Related:** `RFC.md` §7 (SDK generation), `RFC.documentation.md` (OpenAPI/SDK tooling — the framework's headline value-prop). - ---- - -## 1. Context & Problem - -The framework auto-generates `/v1/openapi.json` from `DescribableGroup.Describe()` plus a few special-cased path items (`chatCompletionsPathItem`, `openAPISpecPathItem`) gated by `runtime.Transport.*` flags (`openapi.go` `Build()`). SDK generation (`RFC.md` §7, `RFC.documentation.md`) consumes that spec. - -Two parts of the inference surface we just built are **invisible to the spec/SDKs**: - -1. **Remote / hybrid chat-completions.** The rich `chatCompletionsPathItem` (request/response/SSE/error schemas, tag `inference`) already exists, but `transport.go:53` sets `ChatCompletionsEnabled: e.chatCompletionsResolver != nil` — only the **local** resolver flips it. A `WithChatCompletionsRemote`-only (or hybrid) engine serves `/v1/chat/completions` but it never appears in the spec. -2. **`WithUpstreamRouter` mounted paths.** The router mounts via `r.Any` at the engine root — not a `DescribableGroup`, not special-cased — so its paths are absent entirely. - -Shipping a live inference surface that SDK consumers can't see is incoherent with the framework's purpose. - -## 2. Goals / Non-Goals - -**Goals** -- The chat-completions path item appears in the spec whenever a local resolver **or** a remote backend is configured (local / remote / hybrid). -- Every `WithUpstreamRouter` mounted path appears in the spec as a minimal, honest `POST` proxy item. -- De-dupe: a real item (chat, openapi-spec, swagger, or a `DescribableGroup` path) always wins over the minimal proxy item at the same path. -- Follow the existing special-cased-path mechanism — no new abstraction. - -**Non-Goals** -- Inferring real request/response schemas for generic router paths (the router proxies arbitrary shapes — the minimal item is deliberately loose). -- Documenting all HTTP methods the router's `r.Any` accepts (POST only — see §3). -- Surfacing runtime routing data (model→pool table, adapters) in the static spec. -- Changing how `DescribableGroup` or `chatCompletionsPathItem` themselves work. - -## 3. What Appears in the Spec - -### 3.1 Chat-completions (local / remote / hybrid) -The existing `chatCompletionsPathItem` (full OpenAI request/response/SSE/error schemas, tag `inference`) is emitted whenever chat is configured by either path. No schema change — only the enabling condition widens. The remote backend is OpenAI-shaped (passthrough or adapted), so the existing schema remains accurate. - -### 3.2 Upstream router paths (minimal proxy item) -Each `WithUpstreamRouter` mounted path (from `WithRouterPaths`, default `["/v1/chat/completions"]`) gets a minimal but honest `POST` item: - -- **Method:** `POST` only. The router uses `r.Any`, but documenting all seven methods with freeform bodies is misleading noise; `POST` matches the inference convention and the default path. -- **Tag:** `proxy` (distinct from the real `inference` chat item, so consumers can tell a generic proxy path from the typed chat endpoint). -- **Request body:** generic `object` (`additionalProperties: true`), `required: true`, with the description: *"Selector-routed proxy. The request body must carry the selector field (default `model`); the concrete request/response schema depends on the target upstream/model."* -- **Responses:** `200` with `application/json` (generic `object`) **and** `text/event-stream` (the router streams); `404` (`no_upstream_for_key`); `503` (`upstream_unavailable`, with a `Retry-After` response header) — matching the router's real envelopes. -- **Security:** same `isPublicPathForList` treatment as the other path items (no forced-public; honours configured public paths). - -### 3.3 De-dup rule -The router-path loop runs **after** the chat/openapi-spec special items and the `DescribableGroup` loop. For each router path, normalise it and skip if the `paths` map already has that key. So: -- Router mounted at `/v1/chat/completions` while chat is enabled → only the `inference` chat item (real schema) appears, never a duplicate `proxy` item. -- A router path colliding with the openapi-spec/swagger/group path → skipped. - -## 4. Wiring (4 files) - -1. **`transport.go`** — `TransportConfig`: - - `ChatCompletionsEnabled: e.chatCompletionsResolver != nil || e.chatRemote != nil`. - - New field `UpstreamRouterPaths []string`; in `TransportConfig()`, set from `e.upstreamRouter.paths` when `e.upstreamRouter != nil`, else nil. -2. **`runtime_config.go`** — no change (`Transport: e.TransportConfig()` already carries the new field). -3. **`spec_builder_helper.go`** — `builder.UpstreamRouterPaths = runtime.Transport.UpstreamRouterPaths` (beside the existing `ChatCompletionsEnabled`/`Path` assignments). -4. **`openapi.go`**: - - `SpecBuilder` struct gains `UpstreamRouterPaths []string`. - - New `upstreamRouterPathItem(path string, operationIDs map[string]int) map[string]any` — the §3.2 item. - - `Build()`: after the chat/openapi-spec items and the group loop, iterate `sb.UpstreamRouterPaths`; normalise; `if _, exists := paths[norm]; exists { continue }`; else add `upstreamRouterPathItem`, applying the `isPublicPathForList` security treatment. - - Optional `x-upstream-router-paths` extension (informational, symmetric with `x-chat-completions-*`). - -The data already exists statically at spec-build time: `e.upstreamRouter.paths` (set by `WithRouterPaths`) and `e.chatRemote` (set by `WithChatCompletionsRemote`). No runtime/dynamic lookup. - -## 5. Testing - -Internal spec-builder tests (mirror `openapi_test.go`'s build/parse pattern — construct the `SpecBuilder` from the engine's runtime config, or fetch `/v1/openapi.json`): - -- **Chat in spec — remote-only:** `api.New(WithChatCompletionsRemote(reg))` → `/v1/chat/completions` POST present with the `inference` request/response/SSE schema. `_Good` (the core gap). -- **Chat in spec — hybrid + local:** both still present (local regression guard). `_Good` -- **Chat absent** when neither local nor remote configured. `_Good` -- **Router paths in spec:** `WithUpstreamRouter(reg, WithRouterPaths("/v1/embeddings", "/v1/score"))` → both appear as `POST`, tag `proxy`, generic schema, `404` + `503` responses. `_Good` -- **De-dup (key case):** router mounted at `/v1/chat/completions` with chat enabled → exactly one item at that path, and it's the `inference` chat item (assert tag `inference` / the chat request schema, NOT `proxy`). `_Ugly` -- **De-dup vs spec/swagger path:** a router path colliding with the openapi-spec or swagger path is skipped (real item retained). `_Good` -- **OpenAPI 3.1 validity:** the produced spec still parses/validates (reuse the existing spec-validation test harness). - -Gates: `_Good/_Bad/_Ugly`, `GOWORK=off go test ./ -race`, `go vet ./`, `gofmt`. - -## 6. File Layout - -``` -go/transport.go (mod) ChatCompletionsEnabled |= chatRemote; + UpstreamRouterPaths field + population -go/openapi.go (mod) SpecBuilder.UpstreamRouterPaths; upstreamRouterPathItem(); Build() router loop + dedup; optional x-extension -go/spec_builder_helper.go (mod) builder.UpstreamRouterPaths = runtime.Transport.UpstreamRouterPaths -go/openapi_inference_test.go (new, or extend openapi_test.go) describability tests -``` - -## 7. Future Extensions (out of v1) - -- Real per-path schemas for the generic router via consumer-supplied `RouteDescription`s (the considered-but-deferred option (b) from brainstorming). -- Per-model documentation (enumerate registry keys) — runtime data, deliberately excluded from the static contract. -- Surfacing the MCP HTTP bridge + other un-described engine routes (broader describability sweep). - -## 8. Open Implementation Notes - -- Confirm `e.upstreamRouter` exposes `.paths` and `e.chatRemote` is the field name set by `WithChatCompletionsRemote` (both from the prior specs) at implementation time. -- Confirm `chatCompletionsPathItem`, `isPublicPathForList`, `normaliseOpenAPIPath`, `operationID`, the `paths` map population order, and the `mimeJSON` constant — reuse verbatim. -- Place the router-path loop after the `DescribableGroup` loop so the dedup covers group-contributed paths too. -- The minimal item's schema is generic on BOTH request and response: `{"type":"object","additionalProperties":true}` for the JSON request and JSON response. For the `text/event-stream` response use a generic schema too (`{"type":"string"}` or a free-form object) — do NOT reuse `chatCompletionsStreamSchema()`, which would falsely imply OpenAI chunk shape on a generic proxy whose stream format depends on the upstream. diff --git a/docs/superpowers/specs/2026-06-06-upstream-router-design.md b/docs/superpowers/specs/2026-06-06-upstream-router-design.md deleted file mode 100644 index 26ad9e3..0000000 --- a/docs/superpowers/specs/2026-06-06-upstream-router-design.md +++ /dev/null @@ -1,309 +0,0 @@ -# Upstream Router (`WithUpstreamRouter`) — Design - -- **Date:** 2026-06-06 -- **Status:** Design — approved, pending implementation plan -- **Module:** `dappco.re/go/api` (`core/api/go`) -- **Author:** Snider + Cladius (brainstorming) -- **Related:** `RFC.md` §11 (chat completions), `RFC.providers.md` (gateway), `transformer*.go` (translation), `ssrf_guard.go` (outbound policy) - ---- - -## 1. Context & Problem - -`core/api` has a list-of-endpoints problem: consumers hold a set of upstream model -endpoints (local Ollama, LAN GPU boxes, hosted inference) and have **no first-class -way to load-balance or route across them by a selector key** (typically the `model` -name, but any value). - -What already exists and is reused, not rebuilt: - -- **Translation layer** — `TransformerIn[I,O]` / `TransformerOut[I,O]`, chainable - pipelines, `FieldRenamer`, schema validation (`transformer.go`, - `transformer_in.go`, `transformer_out.go`). -- **Single-target outbound** — `OpenAPIClient` (one base URL), `SSEClient`, - `WebSocketClient`, all funnelled through the SSRF-guarded `doHTTPClientRequest` - (`transport_client.go`). -- **Selector pattern, wrong target** — `ModelResolver` maps `name → backend` but - resolves to **local in-process `inference.TextModel`**, not remote HTTP, and is - loopback-only (`chat_completions.go`). -- **Rate limiting** — `go-ratelimit` (separate module) and `WithRateLimit`. - -`go-proxy` is **not** reusable here — it is a stratum mining proxy -(workers/miners/shares), not an HTTP reverse proxy. - -The missing piece is a **selector-keyed reverse proxy over a pool of HTTP upstreams**, -composing with the existing translators so any consuming package gets transparent -routing: accept a foreign request shape → route by key → translate → dispatch → -translate the response back. - -## 2. Goals / Non-Goals - -**Goals** -- An `api.Option` (`WithUpstreamRouter`) that mounts a router on the Engine and - inherits its auth/CORS/rate-limit/tracing middleware — drop-in for any consumer. -- Route by a pluggable selector key; default reads the JSON `model` field. -- Load-balance within a per-key pool (weighted round-robin) with passive failover. -- Runtime-mutable pool table (hot reconfigure without restart). -- A decision hook to inspect the payload and override/reject routing. -- Stream SSE / `stream:true` responses through untouched; buffer + translate - non-streaming responses. - -**Non-Goals (v1)** -- Active health-check goroutines (failover is passive/inline). -- Sticky/consistent-hash routing (noted future extension). -- Direct upstream selection from the hook bypassing the registry (key-only in v1). -- Per-chunk transformation of live streams (transformers apply to buffered responses - only). -- Mid-stream failover (impossible once response bytes are flowing; documented). - -## 3. Settled Decisions - -| Fork | Decision | -|------|----------| -| Selector source | Pluggable `Selector func`; **default reads JSON body `model`** | -| Streaming | **Hybrid** — stream-through for `text/event-stream`, buffer otherwise | -| LB strategy | **Weighted round-robin + passive failover** (cooldown on failure) | -| Routing seam | **Decision hook + runtime-mutable pool registry** | -| Proxy core | stdlib `net/http/httputil.ReverseProxy` + custom `RoundTripper` that owns selection/failover | -| SSRF | **Block-by-default at registration** — reject loopback/private/link-local/reserved IP literals + metadata hosts via `ssrf_guard.go` primitives; opt-in `AllowPrivateUpstreams(cidrs...)` registry option widens acceptance for local Ollama / LAN. No request-time guard (validation is one-shot at registration). | - -## 4. Public Surface - -```go -// WithUpstreamRouter mounts a selector-keyed reverse proxy on the Engine. -// Mirrors WithChatCompletions: the option sets a field; the Engine mounts at build. -// -// reg := api.NewUpstreamRegistry() -// _ = reg.Set("lemma", api.Upstream{URL: "http://10.0.0.5:8000", Weight: 2}, -// api.Upstream{URL: "http://10.0.0.6:8000", Weight: 1}) -// _ = reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}) // local Ollama fallback -// engine, _ := api.New(api.WithUpstreamRouter(reg)) -func WithUpstreamRouter(reg *UpstreamRegistry, opts ...UpstreamRouterOption) Option - -// Upstream is one backend endpoint in a pool. -type Upstream struct { - URL string // http(s) base URL; validated once at registration - Weight int // weighted RR weight; <=0 treated as 1 - Headers map[string]string // static headers injected on dispatch (e.g. upstream API key) -} - -// UpstreamRegistry is the runtime-mutable, thread-safe pool table (key -> pool). -// Copy-on-write: writes swap an immutable snapshot under a write mutex; reads are -// lock-free via atomic load. -type UpstreamRegistry struct { /* atomic.Pointer[registrySnapshot] + write mutex */ } - -func NewUpstreamRegistry(opts ...RegistryOption) *UpstreamRegistry -func (r *UpstreamRegistry) Set(key string, ups ...Upstream) error // replace pool; validates URL + IP policy -func (r *UpstreamRegistry) Add(key string, up Upstream) error // append one; validates URL + IP policy -func (r *UpstreamRegistry) Remove(key string) // drop a pool -func (r *UpstreamRegistry) SetDefault(ups ...Upstream) error // fallback for unmatched keys -func (r *UpstreamRegistry) Keys() []string // introspection (sorted) - -// RegistryOption configures registration-time validation policy. -type RegistryOption func(*UpstreamRegistry) - -// AllowPrivateUpstreams permits the given private/loopback/reserved CIDRs to pass -// registration validation (default-deny otherwise). Metadata hosts stay hard-blocked. -// -// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8", "10.0.0.0/8")) -func AllowPrivateUpstreams(cidrs ...string) RegistryOption - -// Selector resolves the routing key from the request. body may be nil if unread. -type Selector func(c *gin.Context, body []byte) (key string, err error) - -// RouteFunc inspects the payload and may override the key or reject the request. -// Returning the same key is a no-op; a non-nil error aborts (default 400). -type RouteFunc func(c *gin.Context, key string, body []byte) (newKey string, err error) - -// Router options. -func WithSelector(fn Selector) UpstreamRouterOption // default: JSON body "model" -func WithRouteHook(fn RouteFunc) UpstreamRouterOption // the "add logic later" seam -func WithRouterPaths(paths ...string) UpstreamRouterOption // default ["/v1/chat/completions"] -func WithUpstreamTransformerIn(t ...any) UpstreamRouterOption // reuses compileTransformerPipeline -func WithUpstreamTransformerOut(t ...any) UpstreamRouterOption // buffered (non-stream) responses only -func WithFailover(maxAttempts int, cooldown time.Duration) UpstreamRouterOption // default: len(pool) (each tried once), 10s -func WithFailoverStatuses(statuses ...int) UpstreamRouterOption // default: >=500; 429 opt-in -func WithUpstreamTransport(rt http.RoundTripper) UpstreamRouterOption // custom TLS/timeouts base -``` - -**Contract rules** -- The **registry is the single source of truth** for endpoints; the hook returns a - *key*, the registry resolves it → all LB stays in one place. -- `Set`/`Add`/`SetDefault` **return `error`** — validation happens here, once, never - per request: URL shape (http(s) scheme, host present, port in range) **and** IP - policy. Loopback/private/link-local/reserved IP literals and metadata hosts are - **rejected by default**; `AllowPrivateUpstreams(cidrs...)` widens acceptance. - Non-metadata hostnames are accepted as trusted config without registration-time DNS. -- Transformers reuse `compileTransformerPipeline`/`runTransformerPipeline`, so - `FieldRenamer` and any `TransformerIn[I,O]`/`TransformerOut[I,O]` work unchanged — - but on the router they operate on the **raw upstream JSON body**, *not* the - `{success,data}` OK-envelope (upstream responses are foreign; no unwrap). -- **Same-path forwarding**: each path in `WithRouterPaths` forwards its own - path + query to the chosen upstream base URL. One registry keyed by `model` serves - all OpenAI-shaped paths (`/v1/chat/completions`, `/v1/embeddings`, …). -- The router mounts on the Engine **root router**, so global engine middleware - (auth/CORS/rate-limit/tracing) wraps it. It is **not** a `RouteGroup`, so the - group-transformer middleware does not apply — the router's own transformers do. - -## 5. Components - -Each unit has one purpose and is testable in isolation. - -| Unit | File | Responsibility | Depends on | gin/HTTP? | -|------|------|----------------|------------|-----------| -| `UpstreamRegistry` | `upstream_registry.go` | Copy-on-write pool table; URL validation on write | `sync/atomic`, `net/url` | no | -| `upstreamBalancer` | `upstream_balancer.go` | Weighted-RR pick over a pool; shared per-key cursors + per-upstream cooldown; `markFailed`; injectable `now()` | registry types | no | -| `upstreamTransport` | `upstream_transport.go` | `http.RoundTripper`: pick → rewrite host → inject headers → base.RoundTrip → failover retry | balancer, base `RoundTripper` | http only | -| `upstreamRouterHandler` | `upstream_router.go` | gin handler orchestration + config + default `model` selector; owns one `*httputil.ReverseProxy` | all above + transformer machinery | yes | -| Engine wiring | `options.go`, `api.go` | `WithUpstreamRouter` sets `e.upstreamRouter`; build mounts each path | — | — | - -**State ownership:** cooldown timestamps and RR cursors are **shared, not -per-request** (a dead upstream must stay cooling for all callers). They live in the -balancer behind its own mutex — cursors keyed by selector key, cooldown keyed by -upstream URL. The per-request pool is stashed on `req.Context()` so a single -`ReverseProxy`/transport instance serves every request (no per-request proxy alloc). - -## 6. Data Flow (one request) - -``` -hits mounted path (engine auth/CORS/ratelimit/tracing already ran) - 1. read body once — MaxBytesReader(maxToolRequestBodyBytes) -> 413 on overflow - 2. Selector(c, body) -> key (default: JSON "model"; empty -> 400) - 3. RouteHook(c, key, body) -> finalKey (inspect/override/reject -> 400/403) - 4. TransformerIn pipeline -> rewrite outbound body + ContentLength (400 on err) - 5. registry snapshot -> pool[finalKey] else default (none -> 404 no_upstream_for_key) - 6. bind {finalKey, pool} to ctx -> ReverseProxy.ServeHTTP - - upstreamTransport.RoundTrip (loop <= maxAttempts) - balancer.pick(finalKey, pool) -> up (all cooling -> stop) - clone req; set URL.Scheme/Host=up; inject up.Headers - base.RoundTrip - err or status in failoverStatuses -> balancer.markFailed(up, cooldown); retry next - else -> return resp - - response: - text/event-stream -> FlushInterval:-1 streams through; ModifyResponse passes untouched - else + TransformerOut -> ModifyResponse buffers, transforms raw body, drops Content-Length - - ErrorHandler (all upstreams failed/cooling) -> 503 upstream_unavailable + Retry-After - tracing span attrs: key, upstream.url, retry.count, stream(bool), status -``` - -**Inherent limit:** failover is **pre-response only**. Once a 2xx returns and the -proxy starts copying (especially a live stream), upstreams cannot be switched — a -mid-stream upstream death surfaces to the client. True of every streaming proxy. - -## 7. Error Taxonomy - -Our errors use the framework `Fail`/`FailWithDetails` envelope; backend errors pass -through verbatim. Dividing line is client-error vs infra-error. - -| Condition | Status | Code | Body | -|-----------|--------|------|------| -| Body exceeds `maxToolRequestBodyBytes` | 413 | `request_too_large` | `Fail` | -| Selector can't resolve key (no `model`) | 400 | `invalid_request` | `Fail` | -| Route hook rejects | hook's (default 400) | `routing_rejected` | `Fail` | -| `TransformerIn` fails | 400 | `invalid_request_body` | `Fail` | -| No pool for key **and** no default | 404 | `no_upstream_for_key` | `Fail` | -| Upstream 4xx (non-failover, incl. 429 unless opted-in) | passthrough | upstream's | upstream body verbatim | -| Upstream transport-error / status in failover set | → failover (retry next) | — | — | -| All upstreams failed/cooling | 503 | `upstream_unavailable` | `Fail` + `Retry-After` | -| `TransformerOut` fails | 502 | `invalid_upstream_response` | `Fail` | -| Bad URL at `Set/Add/SetDefault` | — | Go `error` at **config time** | never hits request path | - -- **Failover set is configurable** (`WithFailoverStatuses`); default = transport errors - + status ≥ 500, with 429 opt-in. A non-429 4xx is a deterministic client error → - passed straight through, no retry. -- **Upstream URLs never leak to the client.** The 503 body is generic; selected - upstream, error, and attempt count go to **logs (warn) + trace attributes** only. - -## 8. Security Notes - -- **SSRF posture — block-by-default + explicit opt-in** (aligned with - `pkg/provider/proxy.go`, not bypassed). At registration, `Set`/`Add`/`SetDefault` - reject loopback/private/link-local/reserved IP literals and metadata hosts using the - root `ssrf_guard.go` primitives (`blockedIPReason`). Local Ollama / LAN boxes are - enabled by an explicit `AllowPrivateUpstreams(cidrs...)` registry option (code-level - intent — no env reliance). Non-metadata hostnames are accepted without - registration-time DNS (trusted config). There is **no request-time guard** — - validation is one-shot at registration, so the hot path stays allocation-free. The - dispatch `RoundTrip` carries a **scoped `#nosec` with justification** (upstreams are - registration-validated operator config), mirroring `transport_client.go:493`. -- **No URL leakage** to clients (see §7). -- **Bounded request bodies** via `MaxBytesReader(maxToolRequestBodyBytes)`, reusing - the transformer constant. -- Header injection is per-upstream static config (e.g. upstream API keys) — never - derived from the incoming request, so a client cannot inject upstream auth. - -## 9. Testing Strategy - -Convention: `_Good` / `_Bad` / `_Ugly` suffixes, example tests, `-race`, `GOWORK=off`. - -**Per-unit (pure, fast)** -- `UpstreamRegistry` — Good: http/https + loopback/private accepted; Bad: `ftp://`, - missing host, bad port, `javascript:` rejected at write; Ugly: concurrent - `Set`+snapshot under `-race`, snapshot-before-write provably unaffected (COW). -- `upstreamBalancer` — weighted spread within tolerance over N picks; cooled upstream - skipped until **fake clock** passes cooldown; all-cooling → `pick` returns `!ok`; - `weight<=0`→1; concurrent `pick`/`markFailed` under `-race`. -- `upstreamTransport` — **fake base RoundTripper**: success returns resp; - transport-error → `markFailed` + retry-next → success; status-in-set fails over, - 4xx passes through; all-fail returns last err; asserts header injection + correct - scheme/host rewrite with path preserved. - -**Integration (`httptest` upstreams)** -- Weighted spread roughly matches weights over many requests. -- Failover: A always 503, B 200 → client gets 200, A cooling. -- Streaming: SSE upstream with flushes → client receives chunks incrementally, body - byte-identical, `TransformerOut` not applied. -- Non-stream + `FieldRenamer` out → fields renamed, `Content-Length` corrected; - `FieldRenamer` in → upstream sees renamed body. -- Selector default routes by `model`; missing `model` → 400. Hook overrides key → - different pool; hook reject → 403. -- **SSRF posture**: `127.0.0.1` upstream **rejected at config time by default**; - accepted after `AllowPrivateUpstreams("127.0.0.0/8")`; non-metadata hostname accepted; - metadata host `169.254.169.254` rejected even with a broad allow-list; `ftp://` and - missing-host rejected. Integration: an allowed `127.0.0.1` httptest upstream serves - end-to-end (proves no request-time guard blocks it). -- All-down → 503 + `Retry-After`; assert upstream URL absent from client body. -- Multiple mounted paths each forward their own path. -- Composition: `WithBearerAuth` in front → 401 without token. - -**Gates:** `GOWORK=off go test -race ./...` green; gosec clean (scoped `#nosec`). - -## 10. File Layout - -``` -go/upstream_registry.go + _test.go + _example_test.go -go/upstream_balancer.go + _internal_test.go -go/upstream_transport.go + _internal_test.go -go/upstream_router.go + _test.go + _example_test.go (handler, config, default selector) -go/options.go (+ WithUpstreamRouter, UpstreamRouterOption helpers, e.upstreamRouter field) -go/api.go (+ build-time mount of each path) -go/string_constants.go (+ error codes) -``` - -## 11. Future Extensions (out of v1 scope) - -- Sticky / consistent-hash routing as a selectable strategy. -- Active health checks with a background prober (passive failover stays the default). -- Direct upstream selection from the hook (bypass registry) for advanced cases. -- Per-chunk streaming transformers (translate a foreign SSE format → OpenAI SSE). -- Path rewrite (strip/replace prefix) per upstream. -- Per-pool rate limits via `go-ratelimit` integration. - -## 12. Open Implementation Notes - -- Confirm `maxToolRequestBodyBytes`, `Fail`, `FailWithDetails`, - `compileTransformerPipeline`, `runTransformerPipeline` signatures at implementation - time and reuse verbatim (no forks). -- `ReverseProxy.Rewrite` (Go 1.20+) preferred over the deprecated `Director`; set only - path/query preservation there — the host is set inside `upstreamTransport.RoundTrip` - per attempt. -- `ModifyResponse` must distinguish streaming by response `Content-Type` - (`text/event-stream`) — not by request flags — so an upstream that streams - unexpectedly is still passed through. -- Decide the failover-status default constant set in `string_constants.go`. -- `Upstream.URL` may include a base path (e.g. `http://host/inference`); the incoming - request path is appended to it. Document this in the `Upstream.URL` godoc so the - forwarding rule is unambiguous.