AI Gateway
- Area: Intelligence
- Path:
ai/gateway - Kind: Unified LLM proxy / router (OpenAI-compatible API)
Role in the stack
AI Gateway is the single entry point for any Koder product that needs to call an LLM. It exposes an OpenAI-compatible API and routes requests to the right backend — local LLMs (via ai/runtime + Ollama), or proprietary providers (OpenAI, Anthropic, Google Gemini, HuggingFace, vLLM, Stability, Deepgram) when enabled in config.
Running in production on s.r1 as LXC 128, internal endpoint http://10.0.1.128:7800/v1/. Consumers today include ai/voice (for transcription summarization); new products plug in by pointing their SDK at the gateway without hardcoding provider keys.
Parallel task dispatch (AIGW-051 governor + AIGW-052 HTTP/SSE edge)
internal/dispatch is the multi-tenant concurrency governor for parallel agent sub-runs (AICORE-119 Manus-parity axis): it admits sub-runs under per-user + per-org budgets (LimitsForTier: free 1 / pro 5 / team 20 per user, aligned with ai/runtime's orchestrator), FIFO-queues the excess, fans the artifacts in, and merges each child's event stream annotated by ChildID. The per-user cap stays below the org ceiling so one user never monopolises an org's slots (tenant A never blocks tenant B). The Executor is an interface — the real one forwards sub-runs to ai/runtime's RUNTIME-031 orchestrator.
internal/dispatchapi (AIGW-052 API edge — DONE 2026-05-25) is the HTTPSSE surface over the governor: `POST /apiv1agentparallel accepts {"parallel":[{prompt,tools},…]}, resolves the caller's tier → budget, assigns child ids, and streams event: child per sub-run event (carrying child_id/seq/kind) then a final event: done with the aggregated fan-in; over-budget callers get **429 + Retry-After** (pre-admission load-shed against Stats()); GET apiv1agentparallelstats exposes the caller's live active counts for the "N in queue" UI. Tenant resolution is injected (TenantResolver, default reads auth-middleware context) so the edge is unit-tested with a fake executor (6 tests, -race). **Remaining integration half (AIGW-052):** the real runtime-forwarding Executor, route registration in cmdgateway, and live SSE e2e + KAiParallelTabs` UI.
Scheduled tasks (AIGW-050)
internal/schedule is the scheduled-agent-tasks engine (Manus "Agendado" sidebar): a dependency-free POSIX cron parser (5-field + @daily@hourly@weekly… macros, listsranges/steps, Vixie dom-vs-dow rule), the agent_schedules model + tenant-scoped store, per-tier caps (MaxSchedulesForTier: free 0 / pro 20 / team ∞), and a clock-injectable Scheduler with Tick (fire due + advance) and CatchUp (a miss within 24h fires once; older is skipped).
internal/scheduleapi (AIGW-053 schedule CRUD slice — DONE 2026-05-25) is the HTTP surface over that engine: POST/GET/DELETE/PATCH /api/v1/agent/schedules[/{id}] for createlistgetdeleteenable, tenant-scoped + tier-enforced (Store.Create parses the cron, computes NextRun, enforces the cap; the handler maps invalid-cron → 400, free-tier → 403, over-cap → 403, cross-tenant → 404 per the multi-tenancy contract). Tenant+tier come from the auth context (never the body); 7 tests under -race.
internal/trigger (AIGW-053 inbound-trigger framework + webhook-signed adapter — DONE 2026-05-25) is the inbound side: a small framework (EndpointResolver + Dispatcher interfaces, buildPrompt) plus the signed-webhook adapter. POST /api/v1/triggers/webhook/{endpoint} resolves the endpoint (unknown → 404), caps the body (1 MiB → 413), verifies a constant-time HMAC-SHA256 signature (X-Koder-Signature: sha256=<hex>, 401 on mismatchmissingtampered), then dispatches an agent run as the endpoint's tenant (202; dispatch failure → 502). VerifySignatureSign are the symmetric primitives; 9 tests under -race. The github-PR-comment adapter (github.go) reuses the framework: `POST /apiv1triggersgithub{endpoint} verifies GitHub's X-Hub-Signature-256 HMAC, 200-acknowledges ping`non-commentnon-createdkeyword-less events without dispatch (no retry), and on a /koder-keyword created comment strips the keyword, prefixes the repo#PR context, and dispatches as the tenant (202); +7 tests. The slack-mention adapter (slack.go) verifies the Slack v0 request signature (X-Slack-Signature: v0=<hex> over v0:{ts}:{body}) with a ±5-min anti-replay window (X-Slack-Request-Timestamp, clock injectable), echoes the url_verification challenge, ignores non-app_mention events with 200, and on an app_mention strips the <@…> mention(s) + prefixes the channel and dispatches as the tenant (202); VerifySlackSignatureSlackSign primitives, +7 tests. The trigger suite is 23 tests under -race. The *ersistent schedule.Store (`internalschedulepostgres.go, AIGW-053 — DONE 2026-05-28, /k-go drain)** is a PGStore over pgxpool mirroring MemStore field-for-field (cron-parse + tier-cap in Create inside a tx, multi-tenant scoping → cross-tenant ErrNotFound, Due system scan, MarkRan advance); RunMigrations uses a dedicated gatewayschedulemigrations table so it never collides with internalusage's migrations on a shared DB; integration test /o:build integration (GATEWAYSCHEDULETEST_DSN). **Remaining (AIGW-053, runtime/serve-time):** the real EndpointResolver (endpoint registry + servicesfoundationid linked accounts) + real Dispatcher (AIGW-051/RUNTIME-031), the 2 infra-coupled adapters (email/jira), the per-minute Scheduler.Tick/CatchUp loop, and cmd/gateway` route wiring.
Primary couplings
| Consumer | Relationship |
|---|---|
ai/voice |
Summarizes transcriptions via gateway routing |
| Future products | Any Koder product that needs LLM inference |
Providers (configurable)
- Enabled by default: local Koder AI Runtime (Ollama) on LXC 127
- Available when enabled: OpenAI, Anthropic, Google Gemini, HuggingFace, vLLM, Stability, Deepgram
Interfaces
- OpenAI-compat
/v1/API (chat, completions, embeddings) - YAML configuration at
/etc/koder-ai-gateway/config.yaml - systemd service
koder-ai-gateway - Run notifications (AICORE-144 + observe-RFC-002 / notify#010) — the gateway
POSTs run-lifecycle pushes to self-hosted koder-notify (
/api/v1/notify, no FCM) and proxies the WebPush subscription lifecycle (POST /v1/webpush/{subscribe, unsubscribe}→ koder-notify, JWT-scoped to the caller) so the fan-out has subscriptions to reach. The legacy local FCM-style device registry (internal/devices) is retained pending retirement per RFC-002 §8.
Storage
| Aspect | Current (2026-05-09) | Target (#048) |
|---|---|---|
| Driver | SQLite local (modernc.org/sqlite) |
Postgres via pgx/v5 against kdb LXC 10.0.1.20 |
| Tables | usage_events, usage_daily, usage_monthly (string-based tenant cols) |
Same DDL translated to Postgres dialect (migrations/0001_initial.up.sql) + later llm_token_usage w/ koder_user_id BIGINT + RLS (#043) |
| Multi-tenancy | App-layer-only (no RLS) | RLS policies per policies/multi-tenant-by-default.kmd §3 |
| Time-series | Manual hourly aggregation | Timescale hypertable on usage_events (Phase 2 of #048) |
Caminho ratificado em backlog/done/047-tut-discovery-sqlite-vs-postgres.md (caminho A — migrar pra Postgres antes do tracker centralizado RFC-001). Migration plan em backlog/pending/048-.... Detalhe de current state em docs/technical/storage.kmd.
Agent stream protocol (2026-05-16)
The unified Koder AI product (services/ai/ai/) ships its own gateway that proxies the OpenAI-compatible API AND exposes the Koder Agent Stream Protocol for live agent run subscription.
- Route:
GET /v1/agent/runs/{id}/stream— auto-detects SSE vs WebSocket upgrade - Wire: 7 event types (
reasoning.delta,text.delta,tool.start,tool.end,step.boundary,child.spawn,run.lifecycle) - Throughput shaping (R1-R4): 100ms aggregation by default;
?detail=fullopts out - Reconnect:
Last-Event-ID(SSE) /{op:"resume",seq:N}(WS) replay from persistent store - Auth + tenancy: JWT required (API-key auth refused for streams); cross-tenant → 404 per
specs/multi-tenancy/contract.kmd - Persistence:
RunEventStoreinterface with two backends —InMemoryRunEventStore(default, boot-safe, ephemeral) orKDBRunEventStore(opt-in viaRUN_EVENT_STORE=kdb+KDB_BASE_URLKDB_API_KEYKDB_NAMESPACE). KDB backend runs idempotentCREATE TABLE/INDEX IF NOT EXISTSfrommigrations/001_agent_run_events.sqlon boot. Cross-tenant reads return 0 rows → handler surfaces 404 viaCountForTenant. Retention: 90d done / 30d aborted, pruned hourly. - Reference impls:
- Go:
services/ai/ai/gateway/internal/stream/{events,hub,sse,ws,aggregator,store,batchwriter}.go - JS:
engines/sdk/js/agent-stream/index.js(exported as@koder/sdk/agent-stream)
- Go:
- Tests: 29 Go sub-tests + 7 JS conformance tests, all green under
-race - Tickets: AICORE-120 (parent) → AICORE-127, 128, 129, 130, 131, 132 (all closed 2026-05-16)
Run control plane (2026-05-16)
Companion to the agent stream protocol: REST surface for the out-of-band controls Manus / Devin 2.0 / Cline expose (pause, redirect, rewind, planning gate, follow-up suggestions).
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/agent/runs/{id}/pause |
Suspend run (Running → Paused) |
POST |
/v1/agent/runs/{id}/resume |
Resume paused run |
POST |
/v1/agent/runs/{id}/redirect |
Inject {message, rewind_to?}; emit run.lifecycle=redirecting |
POST |
/v1/agent/runs/{id}/rewind |
Roll back to a checkpoint by id or step-index |
GET POST |
/v1/agent/runs/{id}/checkpoints |
List / create checkpoints (runtime persists diff_ref to blob) |
POST GET |
/v1/agent/runs/{id}/plan |
Save / read the proposed plan |
POST |
/v1/agent/runs/{id}/approve_plan |
Approve with optional edits (smuggled step IDs rejected) |
POST |
/v1/agent/runs/{id}/reject_plan |
Reject → state Aborted |
POST GET |
/v1/agent/runs/{id}/follow_ups |
Save / read 3–5 follow-up suggestions |
GET |
/v1/agent/runs/{id}/events?from_seq=N&format=jsonl |
Export run events (AICORE-117 backend) |
- State machine: see
services/ai/ai/gateway/internal/runctl/types.go— 8 states + transition table; terminal states reject all transitions. - Planning gate modes:
off(Claude-Code-classic),preview(default; agent emits plan and starts running),required(blocks on approval). The runtime decides which based on workspace settings + request. - Multi-tenancy: every endpoint enforces JWT-bound tenant via
auth.ClaimsFromContext; cross-tenant resolution returns 404 perspecs/multi-tenancy/contract.kmd. - Error codes:
AICORE-CTL-0001..0008andAICORE-CTL-0099perspecs/errors/user-facing-messages.kmd. - Persistence:
runctl.Storeinterface (InMemory ships today); kdb-backed impl tracked alongside AICORE-133. - Spec extension:
agent-stream.kmdv0.2.0 adds theplan.proposalevent type. - Tickets: AICORE-117, 121, 122, 123, 126 (gateway slice closed 2026-05-16; runtime consumer slice AICORE-134134.1134.3 closed 2026-05-18; checkpoint+followup concrete impls 134.2134.4 + KITWEBKIT widgets 134.5/134.6 still pending).
- Redirect intent persistence (AICORE-134.1):
/redirectand/rewindroutes now persist arunctl.RedirectIntent(message + resolved rewind target) before transitioning state. The runtime fetches+clears the intent inwaitForRunnableand jumps the Execute loop index when a checkpoint resolved.
Agent runtime (2026-05-16, AICORE-137 first slice)
The muscle that consumes everything shipped above. internal/agent/ owns a Runtime that drives a Plan of (tool, input) steps end-to-end and surfaces the protocol events.
Toolinterface:Name() / Description() / Call(ctx, input) (output, error). Implementations MUST be concurrent-safe. Built-inEchoToolfor smoke tests.ToolRegistry: concurrent-safe map;Register / Get / Names / Len. Overwriting is allowed so tests can swap impls.Runtime.Execute(ctx, Plan):- State pre-check: rejects unless run is in
Running/Paused/Redirecting. - For each step:
waitForRunnable(pollsrunctl.Storeevery 250ms while Paused) → emitstep.boundary→ emittool.start→Tool.Call(ctx, input)(60s timeout) → emittool.end(ok + duration_ms, or error withKAI-TOOL-CALL-0001). Redirectingconsumes the persistedrunctl.RedirectIntent(set by/redirector/rewind), clears it, transitions back toRunning, and when the intent carries a resolvedRewindToCheckpointIDjumps the loop index to the matching step (AICORE-134.1).- Aborted during pause returns
ErrAborted; tool errors transition the run toStateError; unknown tools emittool.endwithKAI-TOOL-LOOKUP-0001and returnErrToolNotFound. - Clean completion transitions to
StateDoneand emits finalrun.lifecycle done. - Checkpoints (AICORE-134.2):
Runtime.Checkpointeris a pluggableCheckpointCaptureinvoked between successful steps; defaultnil(no checkpoints),NopCheckpointCapture{}enables timeline-only. - Follow-ups (AICORE-134.4):
Runtime.FollowUpsis a pluggableFollowUpGeneratorinvoked afterDoneto populate/follow_ups; defaultnil(no suggestions). Validates viarunctl.ValidateFollowUps(3–5 items). - PlanAndExecute (AICORE-134.3): alternate entry that wires
Planner→runctl.Plansave →PlanningGateflow →Execute.IntentFromToolkeyword-classifies steps before SavePlan so UIs render the right icon.
- State pre-check: rejects unless run is in
Sinkinterface mirrors*stream.PersistentSink.Publishso the runtime is decoupled from the Hub + persistence types. Production binds the gateway singleton; tests injectcaptureSink.- Tests: 10 sub-tests verde under
-race(ToolRegistry, EchoTool, Execute happy path / lookup error / paused-then-resumed / aborted / redirect / bad initial state / missing run_id / tool error).
The runtime deliberately leaves the LLM-driven planner to AICORE-137b — this slice ships the executable primitive; planning ships on top of it.
Default tool registry (AICORE-137d)
NewServer builds the agent runtime with agent.DefaultRegistry(fetcher, pipeline), exposing three tools out of the box:
| Tool | Wraps | Purpose |
|---|---|---|
echo |
n/a | Canonical reference / smoke check |
echo_stream |
n/a | StreamingTool reference (AICORE-137c) — emits text.delta per word |
web_fetch_rich |
web.FetchRich |
Static HTTP fetch + boilerplate-stripped markdown |
web_extract |
web.Pipeline.Run |
URL + schema → JSON via LLMExtractor |
Tool inputs mirror the REST body shapes of /v1/tools/web_fetch_rich and /v1/tools/web_extract so Plans can reuse payload templates verbatim. Server.AgentRuntime() and Server.AgentTools() are public accessors for the admin handlers below.
Agent admin endpoints (AICORE-137e)
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/agent/runs |
Create + start a run from a Plan; returns 202 with {run_id, state, stream_url, step_count} |
GET |
/v1/agent/tools |
List the registered tools (name, description) for planner consumption |
- Body:
{steps: [{tool, input?, id?}], prompt?, planner_options?, planning_gate?: off|preview|required, workspace_id?}. Steps OR Prompt must be set (Steps wins when both — debug path). Missing tool names or unknown tools return 400 withKAI-AGENT-PROTO-0003/0004before any run is created. - Async semantics: 202 returns immediately; the runtime runs on a goroutine with a 1h ceiling. Clients subscribe to
stream_urlto observe progress. - Planning gate:
offpreviewstart the runtime immediately;requiredparks the run inawaiting_approval, auto-saves a passthroughrunctl.Plan, and a background watcher polls (10min max) for the user's `POST ...approve_planbefore firingRuntime.Execute`. - Tenancy: every run is registered with
stream.RunRegistryfor the authenticatedkoder_user_idso the live stream route enforces cross-tenant → 404.
This is the end-to-end user surface for the agent — the cycle from "I want X done" → planning → execution → live event stream → completion now closes inside the gateway.
LLM planner (AICORE-137b)
When POST /v1/agent/runs body carries prompt instead of steps, the gateway asks the LLM to turn the prompt into an executable Plan. The planner reads the same tool catalog /v1/agent/tools lists; every step is validated against the registry before any run is created (defense against tool hallucination + prompt-injection of unplanned capabilities).
- Default model:
claude-haiku-4-5-20251001(cheap + JSON-tight). Override per-call viaplanner_options.modelor globally viaEXTRACTOR_MODEL. - Step cap:
planner_options.max_steps(default 8). LLM replies are truncated. - JSON tolerance: the parser strips
json fences and accepts both bare arrays[...]and wrapper objects{steps: [...]} / {plan: [...]} / {actions: [...]}. Accepts bothtoolandnamefield aliases. Step IDs auto-assigned (s1,s2`, ...) when LLM omits. - Error codes:
KAI-AGENT-PLAN-0001(planner not configured) /KAI-AGENT-PLAN-0002(planner returned error: empty prompt, unknown tool, malformed reply, chat upstream failure). - Tests: 15 sub-tests in
internal/agent/planner_test.go+ 3 route-level tests covering prompt-driven happy path, no-prompt-no-steps 400, planner-error 502.
Production binds LLMPlanner to the same LoopbackChatCompleter that backs the LLMExtractor (AICORE-135) — single egress path, single retry policy, same observable surface.
Self-repair loop (AICORE-116)
When a tool call fails AND the configured Matcher(toolName, err) returns true, the runtime re-prompts the planner with the failure context and runs the replacement step. Default-on for test-runner tools (pytestjestplaywrightgotestflutter_test/etc.) with MaxIterations=3.
- Config struct:
agent.RepairConfig{Planner, Matcher, MaxIterations}. Zero-value disables repair (back-compat with #137). - Default:
agent.TestRunnerMatcher()— matches any tool name containing one of 11 test-runner tokens. Extend viaTestRunnerMatcher("custom_runner"). - Replacement step IDs:
<original>_repair_<N>so emittedstep.boundary/tool.start/tool.endevents don't collide on the wire. - Telemetry:
RepairTelemetry.Snapshot()returns(attempts, repaired, failed)atomic counters. Prometheus exposed viakoder_ai_test_repair_attempts_total{outcome}(AICORE-116c) +koder_ai_test_repair_iterations{outcome}histogram (AICORE-116e). - Audit log (AICORE-116d, 116f): every attempt + final outcome emits one
agent.repairslog Info entry with{run_id, koder_user_id, tool, step_id, attempt, outcome, original_error, iterations, ts}.REPAIR_AUDIT_BACKEND=kdbopts intoagent.KDBAuditSink(persistentagent_repair_attemptstable + 12-monthPruneOlderThanretention). Misconfig falls back to slog with WARN. Write path is fire-and-forget so audit infra outages don't disturb the runtime. - Override:
Server.AgentRuntime().Repair = ...before serving traffic to tighten/widen the matcher or disable repair entirely.
Streaming tool output (AICORE-137c)
Tools that produce text-shaped output (markdown, generated content, LLM-extracted streams) can opt into incremental delivery by implementing the StreamingTool interface:
type StreamingTool interface {
Tool
CallStream(ctx, input, chunks chan<- string) (json.RawMessage, error)
}When the runtime encounters a StreamingTool, every value sent on chunks becomes a text.delta envelope between tool.start and tool.end. The final JSON return value is still surfaced via tool.end.output, so replay + persistence remain canonical.
- Backward compatible: non-streaming tools (only
Tool) run unchanged — notext.deltaevents emitted. - Reference impl:
StreamingEchoTool(registered asecho_stream) splits input on whitespace, emits one chunk per word; useful for UI smoke-tests of streaming consumers. - Cancellation:
ctx.Done()aborts streaming cleanly mid-flight.
Provider plane (2026-05-16, AICORE-118)
Provider interface gained two optional extensions and the registry grew a factory layer:
CatalogProvider—Models(ctx) ([]ModelInfo, error). Implemented by all 6 providers (OpenAI, Anthropic, Gemini, Moonshot, Manus, OpenRouter) via static catalogs inproviders/catalog.go.HealthChecker—HealthCheck(ctx) error.HEADagainst each provider's models endpoint; auth-gated 401/403 counts as healthy.- Factory registry —
Registry.RegisterFactory(name, func(apiKey) Provider)+BuildFromKeys(map[name]apiKey)replaces the longif cfg.X != "" { Register(NewX(cfg.X)) }chain inmain.go. Registry.AggregateModels(ctx)— fans out to every CatalogProvider; 1h cache; sorted by (OwnedBy, ID). BacksGET /v1/models(previously a hardcoded list).Registry.Health(ctx)— fans out to every HealthChecker; non-implementers reported as OKfalse + `error"not implemented"(vserror=<probe failure>`).- New route:
GET /v1/providers/health— JSON aggregate.
Web tools (2026-05-16, AICORE-113, AICORE-114)
Two LLM-tool endpoints on the gateway, std-lib only (no chromedp / no readability dep yet).
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/tools/web_fetch_rich |
Static HTTP fetch + boilerplate-stripped markdowntexthtml; metadata + links extraction |
POST |
/v1/tools/web_extract |
URL + schema (flat map of field→type) → JSON; behind Extractor interface (nullExtractor placeholder ships today) |
- Body cap: 5MB on
web_fetch_rich(MaxBodyBytes). - JS render:
opts.js_render=truereturns 501KAI-WEB-FETCH-JS-0001until chromedp engine ships (AICORE-135). - Schema dialect (MVP):
{name: "string", price: "number", in_stock: "boolean"}— full JSON Schema in follow-up. Unsupported types return 400KAI-WEB-EXTRACT-SCHEMA-0001. - LLM step (AICORE-135):
LLMExtractorwires to aChatCompleterinterface; production bindsLoopbackChatCompleter(POSTs to the gateway's own/v1/chat/completions— reuses provider routing, retries, auth). Default modelclaude-haiku-4-5-20251001overridable viaEXTRACTOR_MODEL. Loopback URL/key viaEXTRACTOR_BASE_URL/EXTRACTOR_API_KEY(defaults: in-process URL + any registeredkgw_*key). - Caching:
web_extractkeys onsha256(url + sorted schema), default TTL 1h (opts.cache_ttl_secs < 0disables,0= default). - Auth: JWT-only (API keys forbidden — outbound HTTP is per-user-rate-limited). All routes use
auth.ClaimsFromContext. - Error codes:
KAI-WEB-AUTH-0001,KAI-WEB-FETCH-0001/0002/0099,KAI-WEB-FETCH-JS-0001,KAI-WEB-EXTRACT-0001/0002/0099,KAI-WEB-EXTRACT-SCHEMA-0001. - Tickets: AICORE-113 (rich fetch), AICORE-114 (extract), AICORE-118 (provider refactor backing both), AICORE-135 (LLMExtractor wireup) — all closed 2026-05-16. Follow-up AICORE-136 (chromedp JS render).
Checkpoint retention + erasure cascade (2026-05-23, AICORE-134.2c + AICORE-138)
The retention surface that backs the agent runtime's CheckpointCapture pipeline. Policy lives in meta/docs/stack/policies/checkpoint-retention.kmd; this section is the operator's view.
- Per-run cap (R1):
agent.EnforcePerRunCapruns inline inmaybeCheckpointafter every successful capture. Default 10 envelopes per run;RETENTION_PER_RUN_CAP=-1disables (debug only). - Per-tenant cap (R2):
agent.EnforcePerTenantCapsweeps oldest-across-runs until the tenant's totalSizeBytes≤ cap. Default 500 MiB (RETENTION_PER_TENANT_BYTES). - Done-run grace (R3):
agent.EnforceGraceExpirydrops every checkpoint belonging to terminal runs older than the configured window. Default 7 days, clamps at 90 (RETENTION_DONE_RUN_GRACE_SECS). - Periodic worker:
Server.StartRetentionTickerspawns the cross-tenant ticker that runs R2 + R3 everyRETENTION_INTERVAL(default 15 min). TenantSource derives fromInMemoryRunRegistry.Tenants()— kdb-backed registries will plug in via the sametenantListerinterface. - Multi-tenant isolation (R4): every
RetentionStorecall passeskoder_user_id; cross-tenant deletes mask to silent nil permulti-tenant-by-default.kmd§ 5. - Erasure cascade (R5, AICORE-138):
gateway/internal/erasure/Subscriberconsumesidentity.user.erasedenvelopes from thekoder:events:idRedis Stream (cross-service-events.kmd canonical type + stream key). On every erasure event,agent.EnforceErasurewalks every checkpoint owned by the subjectkoder_user_idand drivesdeleteOnewithReasonErasure. Idempotent — re-delivery on an emptied tenant is a noop. Wired inmain.gowhenREDIS_URLis set; emptyREDIS_URLkeeps R1R2R3 local enforcement without R5. Consumer group:koder-ai-gateway-erasure(pattern<service>-erasure-cascadeper cross-service-events.kmd § R3). - Audit log (R6): every deletion emits a structured slog Info entry
event=checkpoint.deleted run_id=… checkpoint_id=… koder_user_id=… size_bytes=… reason=…wherereason ∈ {per_run_cap, per_tenant_cap, grace_expired, erasure}. ShareddeleteOnehelper guarantees the same shape regardless of which enforcer fired. - Blob store:
agent.FsBlobStorerooted atCHECKPOINT_DIR(default/var/lib/koder-ai-gateway/checkpoints); content-addressed (<tenant>/<scheme>/<sha256>); cross-tenantGet/Deletemask toErrBlobNotFound. - Restore path:
Server.SetRewindRestorersplugs aRestorerRouter; default boot wiresNopRewindRestorer+TarZstdRewindRestorer.OverlayfsRewindRestorer(AICORE-134.2b) blocked on AICORE-106 sandbox-model RFC. - Tickets: AICORE-134.2c (BlobStore + RewindRestorer + R1R2R3R4R6 + periodic worker + main.go wiring) closed 2026-05-19; AICORE-138 (R5 erasure cascade subscriber) closed 2026-05-23. AICORE-134.2b (overlayfs producer + restorer) still pending.
Per-identity provider-key resolution (2026-05-31, AIGW#056)
The gateway resolves a caller's stored BYO provider key (OpenAIGeminiAnthropic/…) by identity and scope precedence, per stack-RFC-007 §6. It is the gateway-side consumer of the Koder ID keys service's gRPC ProviderKeysService.ResolveProviderKey (the server-to-server-only surface that returns the plaintext secret; the HTTP surface is user-CRUD only).
- Key precedence in
provider.effectiveKey: per-request BYOK header → resolved per-identity key (user → workspace → org, applied server-side by id#194) → static Stack-managed key. The resolution tier is installed via a process-wideprovider.SetKeyResolverhook (reuse-first: no per-factory signature churn) and read by openaianthropicgoogleeffectiveKey(covers the OpenAI-compatible siblings — DeepSeekxAIMistralKimiGroqPerplexityTogether). - Resolver (
internal/providerkeys): gRPC client + short-TTL cache (default 60s; positive and negative caching so the common no-BYO-key path doesn't hammer the service) + scope metric. Fail-open: a genuine miss (NotFound — also covers cross-tenant, which the keys service masks to NotFound, never leaking existence) falls through to the static key; a transient keys-service outage does not hard-fail the request (it's not cached, retried next request). Rotation reflects withinCacheTTL;Invalidateavailable for explicit signals. - Identity carrier: the request handler injects
provider.Identity{UserID, WorkspaceID (X-Workspace-ID), OrgID}into the request context next to BYOK injection; the resolver reads it. - Config:
[keysService] enabled + address + cacheTTL + timeout; the startup dial is optional and fail-open (disabled config or dial failure = prior static-key behavior). go.mod consumes the id enginepkgmodule viareplace(the "pkg is the Go SDK" pattern) for the generatedkeysv1client. - Metrics:
aigw_provider_key_resolution_total{scope}(scope ∈ user|workspace|org|none) +aigw_provider_key_resolution_unavailable_total. - Tickets: AIGW#056 closed 2026-05-31 (closes id#194's gateway acceptance); follow-up AIGW#059 (billing attribution to the resolved key's owner — the resolver already reports the scope; usage-tracker wiring pending).
Workspace-scoped API keys + RFC-017 §7 quota cascade (2026-06-02, AIGW#058)
The gateway is the request-time enforcement consumer for the RFC-017 §7 tenancy-limits cascade; the authoritative resolver lives in the id engine (engine#199). The gateway-side machinery:
internal/tenancy(new):MinLimit(org, workspace)is a byte-for-byte replica of the id engine's canonical pure cascade (min(org, workspace), NULL = unlimited, workspace may only tighten). It is duplicated — not imported — because the canonical copy sits in the id engine'sinternal/service package, unimportable across module+internal boundaries; the ticket sanctions this precisely because the function is pure. PlusOverLimit(inclusiveused ≥ limit), theResolverseam (EffectiveLimitResolveWorkspace) with anUnlimiteddefault, aUsageReaderseam, andQuotaEnforcerwhich fails open on any resolverusage error (always-on posture — a tenancy-service blip never takes the data plane down).- Key scope (
internal/middleware/apikeys.go): the canonical AIapi_keysstore gainsworkspace_idproject_id, carried through CreateValidateListRotate. Migration is additive + legacy-safe (duplicate-column-tolerantALTER TABLE) so existingapikeys.dbfiles upgrade in place.POST /api/v1/keysaccepts the two new fields. - Default-workspace absorption:
CombinedAuth.withWorkspaceresolves a legacy org-only key onto the org's default workspace at read time, without mutating the key row (RFC-017 §7), on both managed-key and OIDC paths; the effective workspace lands onUserInfo.WorkspaceID+ aworkspaceIDKeycontext value (GetWorkspaceID). Defaulttenancy.Unlimitedresolver = explicit workspace passes through, org-only stays personal — exactly the pre-#058 behaviour until the id-backed resolver is wired viaCombinedAuth.SetResolver. - Quota enforcement: the tiered rate limiter gains an optional
QuotaEnforcer(SetQuotaEnforcer); admission enforces themin(org, workspace)monthly ceiling → 429monthly_token_limit_exceeded. nil enforcer = no monthly-ceiling enforcement. - Serve-time seam (remaining): the real
Resolverover the id limits endpoint (idengine#200) + realUsageReader(month-to-date from `internalusage), wired incmdgateway. Until then the gateway runsUnlimited(quota enforcement is a no-op, backward compatible). The store now carriesworkspace_id`, so idengine#200's "archive workspace revokes its keys" lockstep can scope a future revoke-by-workspace path. - Tickets: AIGW#058 closed 2026-06-02; built + race-tested on
dev-linux-box.
Billing attribution to the resolved key owner (2026-06-02, AIGW#059)
Closes the loop opened by AIGW#056: when a workspace/org BYO provider key pays for a request, the spend is credited to that owner — not only the triggering user (stack-RFC-007 §6).
- Attribution sink (
internal/provider):Attribution{Scope}+WithAttributionAttributionFromRecordResolvedScope— a per-request pointer the request handler installs before dispatch and the key resolver (deep in provider dispatch) fills with the scope of the key it resolved. The httptrace pattern: a downstream callee records into the same struct the upstream handler reads, with no hot-path signature churn. - Resolver (
internal/providerkeys): on a positive resolution records the resolved scope (userworkspaceorg) into the sink — on both the fresh RPC and the cache hit (the cache entry now carries the scope, so the common 60s-cached path still attributes correctly). A miss leaves the sink empty. - Usage event (
internal/usage):EventgainsWorkspaceID(RFC-017 §6 materialized path) +KeyOwnerScope; persisted in theusage_eventslog on sqlite (additive ALTER) and postgres (migration0004_usage_event_attribution). - Handler classification (
cmd/gateway): on Record setsWorkspaceID(the #058-absorbed workspace, X-Workspace-ID fallback) andKeyOwnerScope=byok(per-request header) | the resolved per-identity scope |stack(Stack-managed key). - Deferred: re-graining the
usage_dailyusage_monthlyrollups + the billingUsageRollupQuery(RFC-017 §7 SUM) to key onworkspace_id— that aggregation lives in `servicesfoundation/billing`; the event log now carries the axis. Quota consuming the resolved owner is the #058 serve-time seam (id#200). - Tickets: AIGW#059 closed 2026-06-02; also fixed a pre-existing test-mock data race in
internal/usage/ratelimit_test.go.
Grounded-search capability (2026-06-02, AIGW#057)
A dedicated route (grounding is not a chat-completion) that returns source citations from a grounding-capable provider, per stack-RFC-007 §7. The first consumer is edictus' aioverview; the requestresponse shapes were ported from that field-tested code (it lives in another module's `internal`, so unimportable).
POST /v1/grounded-search{query, max}→{citations:[{title, url, confirmed:false}]}. Citations come from the provider'sgroundingMetadata.groundingChunks[].web, never the model prose, and are alwaysconfirmed:false— the consumer must validate each URL against its source.internal/groundedsearch: calls GeminigenerateContentwith thegoogle_searchtool, dedups, caps atmax(default 10), and resolvesvertexaisearch …/grounding-api-redirect/…URLs to their final source (best-effort, 5s-bounded, falls back to the raw URL).- Key resolution: no provider key in the payload — the handler resolves the google key by identity via the shared
provider.EffectiveKey(ctx, "google", static)(BYOK header → per-identity AIGW#056 → static Stack key). That helper was extracted from the per-providereffectiveKeymethods (googleopenaianthropic now delegate to it — DRY). - Metrics:
aigw_grounded_search_total{status}+aigw_grounded_search_citations_total; latency on the existing duration histogram (provider=google,model=grounded-search). - Provider fallback (when the primary lacks grounding) is a future seam — google is the only grounding provider today.
- Tickets: AIGW#057 closed 2026-06-02.
Agent-run persistence + skill-mining read API (2026-06-02, AIGW#055)
The gateway owns the agent-run record but had no queryable store; this adds one so the skill-mining worker (tools#020) can read recurring prompt+tool-sequence patterns.
internal/runs(new):Run{ID, KoderUserID, OrgID, WorkspaceID, Prompt, Tools[], Rating, At}+ aStore(RecordRateTenantsTenantRuns) and a write-onlyRecorderseam. SQLite-backed (agent_runstable, file-backed — the gateway's sqlite-first posture, same as apikeys); a Postgreskdb impl can drop in behindStorefor scale.Rateis tenant-scoped →ErrNotFoundon cross-tenant/absent (multi-tenant-by-default §5, no existence leak).- Read API (service-account gated, mirroring the smart-router admin routes):
GET /v1/runs/tenantsandGET /v1/runs?tenant=&since=return the exactmining.RunJSON projection (id, koder_user_id, prompt, tools, rating, at) the worker'sRunSourcedecodes. The worker iterates all tenants → a privileged service read. - Rating endpoint:
POST /v1/runs/{id}/rating {rating:1..5}— caller-authenticated, tenant = the authenticated user, cross-tenant/absent → 404 (the AICORE-124 rating feed). - Recording seam:
dispatchapi.Handler.WithRecorderpersists each completed parallel sub-run (prompt + ordered tools + tenant, unrated); nil = no-op. End-to-end recording activates when the parallel-dispatch routes are wired in prod (the serve-time half of AIGW#052) — the store + read API are the standalone prerequisite and are live now. - Config:
runs.databasePath(defaultruns.db). - Follow-up (different component): the worker's HTTP
RunSourceimpl inservices/ai/tools(replacingFakeRunSource) + the 3-rated-runs end-to-end mining test. - Tickets: AIGW#055 closed 2026-06-02.