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/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".
-
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.
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/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) {
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 10362d2..2a84733 100644
--- a/go/go.mod
+++ b/go/go.mod
@@ -3,13 +3,14 @@ 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
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 53349ba..efe9660 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=
@@ -14,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=
@@ -28,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=
@@ -36,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=
@@ -60,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=
@@ -94,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=
@@ -124,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=
@@ -174,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=
@@ -191,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=
@@ -203,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=
@@ -223,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=
@@ -255,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=
@@ -265,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=
@@ -291,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=
@@ -316,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=
@@ -332,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=
@@ -340,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",
+}