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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,43 @@ See [docs/components.md](docs/components.md) and [docs/reference/presets.md](doc
| `FORCE_MODEL` | — | overwrite the request `model` (eval-containers `EVAL_MODEL`) |

Routes: `POST /openai/v1/chat/completions`, `POST /anthropic/v1/messages`, `GET /healthz`,
`GET /stats` (savings rollups), `GET /expand?id=` (recover an offloaded original). Per-request: header
`GET /stats` (savings rollups), `GET /expand?id=` (recover an offloaded original), and — with
`--dashboard` — `GET /dashboard/` plus `/api/*`. Per-request: header
`x-context-guru-session` sets the session key; `x-context-guru-bypass: true` skips the pipeline.

## Dashboard

`--dashboard` adds a persistent observability UI at `/dashboard/` plus a JSON/SSE API at
`/api/*`. It exists to answer the question the product exists to answer — **what value is
context-guru providing?** — and to make the answer falsifiable.

```sh
context-guru-proxy --preset codesmart --dashboard
# open http://localhost:4000/dashboard/
```

[![The context-guru dashboard](docs/img/dashboard/01-overview.jpg)](docs/dashboard.md)

- **Four labelled savings denominators**, because a single "savings %" is a lie of
omission: of what we tried to compact · of new provider-billed input · of the whole
request (diluted) · unique-of-whole. Each one states what it divides by, and reports
**n/a** rather than a number it cannot compute.
- **Baseline vs actual cumulative cost**, with the saved area shaded, plus an honest
savings **waterfall** that will show a negative net if we spent more than we saved.
- **The cost of our own safety mechanisms beside their benefit** — cache-frozen tokens,
restorations, reverts, and context-guru's own latency and LLM spend.
- **Per-component economics**: unique vs gross savings, `overcount_ratio`, own latency, and
a verdict — so a component that burns wall time for nothing is obvious without a doc.
- **Sessions, requests, and the before/after Git-style diff** of exactly what was removed.
- **Benchmark ingestion** straight from `summary.json` + `rows-*.json`, with cost-vs-reward
per arm and per-task drill-down.

Embedded via `go:embed` — no CDN, no npm, no build step, so it works air-gapped. Capture is
off the hot path (**~175 ns** per request, drops rather than blocks) and redaction happens
before anything reaches disk. `/stats` is unchanged.

Full guide: **[docs/dashboard.md](docs/dashboard.md)**.

## The pipeline

Every component operates on tool-output messages. **Reformat** = lossless repack; **Offload** = drop
Expand Down Expand Up @@ -182,7 +216,8 @@ Details in [docs/integrations.md](docs/integrations.md).

## Docs

- [docs/design.md](docs/design.md) — architecture: component model, fail-open pipeline, store, session, expand loop, metrics.
- [docs/design.md](docs/design.md) — architecture: component model, fail-open pipeline, store, session, expand loop, metrics, the dashboard's capture/store layer.
- [docs/dashboard.md](docs/dashboard.md) — the persistent observability dashboard: metrics semantics, the diff view, storage, access gating, API.
- [docs/components.md](docs/components.md) — every registered component: how it works, live before→after, lossiness, config, best use.
- [docs/integrations.md](docs/integrations.md) — proxy gateway vs AuthBridge plugin, with request paths.
- [docs/setup.md](docs/setup.md) — setup + a concrete SWE-bench run through the eval-containers gateway.
Expand Down
91 changes: 82 additions & 9 deletions apply/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,33 @@ type slot struct {
lossless bool // wholeMessage: does bifrost round-trip this message without dropping fields
}

// Trace is the per-request record of what BodyFull actually did: the resolved
// session, the pipeline's own run report (per-component accounting), the
// before/after text of every rewritten message, and the cache-awareness facts
// that decided which messages were even eligible. It is the dashboard's capture
// input — the same material CONTEXT_GURU_DUMP writes to a file, handed to a
// caller instead. Purely observational: nothing on it affects the rewrite.
type Trace struct {
Session string
Bypassed bool
CacheAware bool
MaxCachedIdx int
// Messages is the normalized message count this request carried.
Messages int
// AttemptedTokens is the token count of the messages age/supersession
// offloaders were ALLOWED to touch (the uncached tail when cache-aware, the
// whole request otherwise). It is the honest denominator for
// "saved / attempted-to-compress"; TokensBefore−AttemptedTokens is the
// compaction our own cache-safety mechanism deliberately gave up.
AttemptedTokens int
// FrozenTokens is TokensBefore−AttemptedTokens: the cost of cache safety.
FrozenTokens int
// Run is the pipeline's aggregate report (nil when the pipeline never ran).
Run *components.RunReport
// Changes lists each rewritten message's before/after text (clipped).
Changes []Change
}

// Body runs the pipeline with no LLM clients available (deterministic components
// only). See BodyWithModel to supply model clients for LLM-based components.
func Body(ctx context.Context, pipe *components.Pipeline, st store.Store, provider bschemas.ModelProvider, body []byte, explicitSession string, bypass bool) ([]byte, bool) {
Expand Down Expand Up @@ -112,7 +139,26 @@ func BodyWithModelWindow(ctx context.Context, pipe *components.Pipeline, st stor
// cache-awareness when the backend is a prompt-caching provider or the request
// already carries cache_control breakpoints; "on" forces it; "off" restores the
// legacy compact-everything behavior (correct for confirmed non-caching backends).
func BodyFull(ctx context.Context, pipe *components.Pipeline, st store.Store, provider bschemas.ModelProvider, body []byte, explicitSession string, bypass bool, models components.ModelSpec, window int, cacheMode string) (result []byte, changedBody bool) {
func BodyFull(ctx context.Context, pipe *components.Pipeline, st store.Store, provider bschemas.ModelProvider, body []byte, explicitSession string, bypass bool, models components.ModelSpec, window int, cacheMode string) ([]byte, bool) {
out, changed, _ := BodyTrace(ctx, pipe, st, provider, body, explicitSession, bypass, models, window, cacheMode)
return out, changed
}

// BodyTrace is BodyFull plus an observational Trace of what happened — the
// resolved session, the pipeline's run report, the cache-awareness facts, and
// each rewritten message's before/after text. The dashboard's capture path uses
// it; every other caller wants BodyFull. The rewrite is byte-identical either
// way: the trace is filled from values the rewrite already computes.
func BodyTrace(ctx context.Context, pipe *components.Pipeline, st store.Store, provider bschemas.ModelProvider, body []byte, explicitSession string, bypass bool, models components.ModelSpec, window int, cacheMode string) ([]byte, bool, Trace) {
var tr Trace
tr.Bypassed = bypass
out, changed := bodyFull(ctx, pipe, st, provider, body, explicitSession, bypass, models, window, cacheMode, &tr)
return out, changed, tr
}

// bodyFull is the rewrite itself. tr is never nil; it accumulates the
// observational trace as the rewrite proceeds and is otherwise inert.
func bodyFull(ctx context.Context, pipe *components.Pipeline, st store.Store, provider bschemas.ModelProvider, body []byte, explicitSession string, bypass bool, models components.ModelSpec, window int, cacheMode string, tr *Trace) (result []byte, changedBody bool) {
// Top-level fail-open backstop: the per-component recover in pipeline.runOne only
// covers component code. A panic anywhere else on the rewrite path (normalize, the
// sjson splice, rebuildCountChanged, a marshal) must NOT 500 the client — forward
Expand Down Expand Up @@ -171,6 +217,11 @@ func BodyFull(ctx context.Context, pipe *components.Pipeline, st store.Store, pr
CacheAware: cacheAware,
MaxCachedIdx: maxCachedIdx,
}
tr.Session, tr.CacheAware, tr.MaxCachedIdx, tr.Messages = sessionID, cacheAware, maxCachedIdx, len(norm)
// The eligible (attempted) denominator: what age/supersession offloaders were
// allowed to touch. Everything before MaxCachedIdx is frozen for cache safety —
// the cost of that mechanism, reported next to its benefit.
tr.AttemptedTokens = attemptedTokens(norm, c)

// Canonical form of each normalized message BEFORE the pipeline, so a
// count-changing component (summarize) can be mapped back to the body.
Expand All @@ -179,7 +230,13 @@ func BodyFull(ctx context.Context, pipe *components.Pipeline, st store.Store, pr
normPre[i], _ = json.Marshal(norm[i])
}

pipe.Run(chat, c)
tr.Run = pipe.Run(chat, c)
if tr.Run != nil {
tr.FrozenTokens = tr.Run.TokensBefore - tr.AttemptedTokens
if tr.FrozenTokens < 0 {
tr.FrozenTokens = 0
}
}

// A component changed the message count (summarize restructures the transcript
// to [msg0, <summary>, last-K]). Rebuild the messages array preserving each
Expand All @@ -197,7 +254,7 @@ func BodyFull(ctx context.Context, pipe *components.Pipeline, st store.Store, pr
// The tail split already rewrote `body`, so the result must be forwarded even
// if no component changes a message.
changed := systemSplit
var changes []change
var changes []Change
for i := range chat.Input {
s := slots[i]
switch s.kind {
Expand Down Expand Up @@ -235,6 +292,7 @@ func BodyFull(ctx context.Context, pipe *components.Pipeline, st store.Store, pr
changes = append(changes, mkChange(s.path, schema.MessageText(pm), schema.MessageText(chat.Input[i])))
}
}
tr.Changes = changes
if changed && dumpPath != "" {
dumpChanges(c.Session, changes)
}
Expand Down Expand Up @@ -327,18 +385,33 @@ func putLen(st store.Store, session string, n int) {
st.Put("cg:len:"+session, []byte(strconv.Itoa(n)))
}

// change is one rewritten message, captured for the CONTEXT_GURU_DUMP trace so a
// human can see exactly what context-guru did to the wire.
type change struct {
// attemptedTokens sums the tokens of the messages an age/supersession offloader
// was allowed to touch this turn (Ctx.TailOnly). With cache-awareness off it is
// the whole request; with it on it is the uncached tail, and the difference is
// what cache safety cost us in foregone compaction.
func attemptedTokens(norm []bschemas.ChatMessage, c *components.Ctx) int {
n := 0
for i := range norm {
if c.TailOnly(i) {
n += schema.TextTokens(schema.MessageText(norm[i]))
}
}
return n
}

// Change is one rewritten message, captured for the CONTEXT_GURU_DUMP trace and
// for the dashboard's before/after diff view, so a human can see exactly what
// context-guru did to the wire.
type Change struct {
Path string `json:"path"`
BeforeTokens int `json:"before_tokens"`
AfterTokens int `json:"after_tokens"`
Before string `json:"before"`
After string `json:"after"`
}

func mkChange(path, before, after string) change {
return change{
func mkChange(path, before, after string) Change {
return Change{
Path: path, BeforeTokens: schema.TextTokens(before), AfterTokens: schema.TextTokens(after),
Before: clip(before, 4000), After: clip(after, 4000),
}
Expand All @@ -358,7 +431,7 @@ func clip(s string, n int) string {
var dumpPath = os.Getenv("CONTEXT_GURU_DUMP")

// dumpChanges appends one JSON line describing this request's rewrites.
func dumpChanges(session string, changes []change) {
func dumpChanges(session string, changes []Change) {
f, err := os.OpenFile(dumpPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
Expand Down
146 changes: 145 additions & 1 deletion cmd/context-guru-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ import (
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"time"

"github.com/rossoctl/context-guru/components"
_ "github.com/rossoctl/context-guru/components/all"
"github.com/rossoctl/context-guru/config"
"github.com/rossoctl/context-guru/dash"
"github.com/rossoctl/context-guru/internal/buildinfo"
"github.com/rossoctl/context-guru/internal/cheapmodel"
"github.com/rossoctl/context-guru/internal/modelinfo"
"github.com/rossoctl/context-guru/metrics"
Expand All @@ -36,6 +40,29 @@ func main() {
anthropic = flag.String("anthropic-upstream", envOr("ANTHROPIC_UPSTREAM", "https://api.anthropic.com"), "Anthropic upstream base URL")
bob = flag.String("bob-upstream", envOr("BOB_UPSTREAM", ""), "Bob (BobShell) backend base URL; enables the Bob gateway routes when set (e.g. https://api.us-east.bob.ibm.com)")
storeFlag = flag.String("store", envOr("STORE", ""), "override state store: true|false (default: config store.enabled, else on)")

// Dashboard. Off by default so an existing deployment's behavior and route
// table are unchanged until asked for; on, it adds /dashboard/ + /api/*.
// NOTE: deliberately NO "disable observability in production" gate — for a
// tool whose value IS observability, that would be backwards.
dashOn = flag.Bool("dashboard", envBool("DASHBOARD", false),
"enable the persistent dashboard (embedded UI at /dashboard/, JSON+SSE at /api/*)")
dashDB = flag.String("dashboard-db", envOr("DASHBOARD_DB", "./context-guru-dashboard.db"),
"dashboard SQLite path; ':memory:' keeps history in RAM only (lost on restart)")
dashRetain = flag.Duration("dashboard-retention", envDuration("DASHBOARD_RETENTION", 7*24*time.Hour),
"drop dashboard rows older than this (0 = no age limit)")
dashMaxBytes = flag.Int64("dashboard-max-bytes", int64(envInt("DASHBOARD_MAX_BYTES", 512<<20)),
"cap the dashboard database size, dropping oldest rows first (0 = no size limit)")
dashContent = flag.Bool("dashboard-content", envBool("DASHBOARD_CONTENT", true),
"capture before/after message text for the diff view (redacted and size-capped before storage)")
dashContentCap = flag.Int("dashboard-content-cap", envInt("DASHBOARD_CONTENT_CAP", 16<<10),
"maximum bytes stored per captured before/after blob")
dashQueue = flag.Int("dashboard-queue", envInt("DASHBOARD_QUEUE", 4096),
"capture channel depth; a full channel DROPS events (counted, and shown in the UI) rather than delaying a request")
dashCIDRs = flag.String("dashboard-trusted-cidrs", envOr("DASHBOARD_TRUSTED_CIDRS", ""),
"comma-separated CIDRs allowed to view per-request CONTENT and the effective config (loopback always is; aggregates are open)")
dashBench = flag.String("dashboard-bench-dirs", envOr("DASHBOARD_BENCH_DIRS", ""),
"comma-separated directories of benchmark runs (each with summary.json + rows-*.json) to ingest")
)
flag.Parse()

Expand All @@ -54,6 +81,44 @@ func main() {
log.Fatalf("build pipeline: %v", err)
}

windows := modelWindows()

var rec *dash.Recorder
if *dashOn {
opts := dash.Options{
DBPath: *dashDB,
RetentionAge: *dashRetain,
RetentionBytes: *dashMaxBytes,
CaptureContent: *dashContent,
ContentCap: *dashContentCap,
QueueSize: *dashQueue,
TrustedCIDRs: splitComma(*dashCIDRs),
BenchDirs: splitComma(*dashBench),
Preset: cfg.Preset,
Mode: dash.ModeActive,
Effective: effectiveConfig(cfg, addr, *openai, *anthropic, *bob, *dashDB, *dashContent, *dashCIDRs),
}
// A negative retention means "no limit"; a zero means "use the default". Map
// an explicit 0 from the flag to "no limit", which is what a user typing 0 means.
if *dashRetain == 0 {
opts.RetentionAge = -1
}
if *dashMaxBytes == 0 {
opts.RetentionBytes = -1
}
r, err := dash.NewRecorder(opts)
if err != nil {
log.Fatalf("dashboard: %v", err)
}
rec = r
defer rec.Close()
if runs, tasks := rec.DB().IngestBenchRoots(opts.BenchDirs); runs > 0 {
slog.Info("dashboard: ingested benchmark runs", "runs", runs, "tasks", tasks)
}
slog.Info("dashboard enabled", "url", "http://"+addr+"/dashboard/", "db", rec.DB().Path(),
"content_capture", *dashContent)
}

h := proxy.New(pipe, cfg.NewStore(), agg, proxy.Options{
OpenAIUpstream: *openai,
AnthropicUpstream: *anthropic,
Expand All @@ -66,7 +131,10 @@ func main() {
CheapModel: cheapModelFromEnv(), // static "config"-source LLM for NeedsModel components
InjectExpand: os.Getenv("INJECT_EXPAND"), // auto (default) | always | never
CacheMode: os.Getenv("CACHE_MODE"), // auto (default) | on | off — cache-aware compaction
Windows: modelWindows(), // dynamic context-window resolver (fraction triggers)
Windows: windows, // dynamic context-window resolver (fraction triggers)
Prices: priceResolver(windows), // per-token rates, so each captured request is priced at write time
Preset: cfg.Preset,
Dashboard: rec, // nil unless --dashboard

// Per-request /compact override: swap the pipeline (?preset / header) while
// keeping this config's component blocks. nil-safe in the handler.
Expand Down Expand Up @@ -99,6 +167,82 @@ func loadConfig(path, preset string) (*config.Config, error) {
return config.LoadBytes([]byte("preset: " + preset + "\n"))
}

// splitComma splits a comma-separated flag value into trimmed, non-empty items.
func splitComma(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}

// priceResolver returns the Pricer side of the window resolver, when it has one.
// A nil Pricer means "no rates known", and every captured row is then marked
// partially accounted rather than priced as free.
func priceResolver(r modelinfo.Resolver) modelinfo.Pricer {
p, _ := r.(modelinfo.Pricer)
return p
}

// effectiveConfig assembles the RESOLVED configuration for the dashboard's config
// view — preset expanded, pipeline as actually built, upstream bases and dashboard
// settings included. It is key-allowlisted by dash.RedactConfig before serving, and
// deliberately carries no credential: keys are read from the environment at use
// time and never copied into this map.
func effectiveConfig(cfg *config.Config, addr, openai, anthropic, bob, dbPath string, content bool, cidrs string) map[string]any {
comps := map[string]any{}
for name, node := range cfg.Components {
var v any
if err := node.Decode(&v); err == nil {
comps[name] = v
}
}
return map[string]any{
"preset": cfg.Preset,
"pipeline": cfg.Pipeline,
"components": comps,
"listen_addr": addr,
"openai_upstream": openai,
"anthropic_upstream": anthropic,
"bob_upstream": bob,
"force_model": os.Getenv("FORCE_MODEL"),
"cache_mode": envOr("CACHE_MODE", "auto"),
"inject_expand": envOr("INJECT_EXPAND", "auto"),
"cheap_model": os.Getenv("CHEAP_MODEL"),
"cheap_model_provider": envOr("CHEAP_MODEL_PROVIDER", "anthropic"),
"store": map[string]any{"ttl_seconds": cfg.Store.TTLSeconds, "max_entries": cfg.Store.MaxEntries},
"dashboard": map[string]any{"db_path": dbPath, "capture_content": content, "trusted_cidrs": cidrs},
"build_version": buildinfo.Version,
"build_commit": buildinfo.Commit,
}
}

// envBool reads a permissive boolean environment variable.
func envBool(key string, def bool) bool {
if v, ok := parseBool(os.Getenv(key)); ok {
return v
}
return def
}

// envInt reads an integer environment variable, falling back on anything unparseable.
func envInt(key string, def int) int {
if v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(key))); err == nil {
return v
}
return def
}

// envDuration reads a Go duration environment variable (e.g. "72h").
func envDuration(key string, def time.Duration) time.Duration {
if d, err := time.ParseDuration(strings.TrimSpace(os.Getenv(key))); err == nil {
return d
}
return def
}

func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
Expand Down
Loading
Loading