Skip to content

Commit 544db53

Browse files
authored
Merge pull request #12 from rossoctl/feat/bob-gateway
feat(proxy): Bob (BobShell) gateway — reduce model calls, pass control-plane through
2 parents 3dd66e9 + 8cb9c1c commit 544db53

4 files changed

Lines changed: 166 additions & 2 deletions

File tree

cmd/context-guru-proxy/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func main() {
3333
preset = flag.String("preset", envOr("PRESET", "balanced"), "preset to use when --config is absent")
3434
openai = flag.String("openai-upstream", envOr("OPENAI_UPSTREAM", "https://api.openai.com"), "OpenAI upstream base URL")
3535
anthropic = flag.String("anthropic-upstream", envOr("ANTHROPIC_UPSTREAM", "https://api.anthropic.com"), "Anthropic upstream base URL")
36+
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)")
3637
storeFlag = flag.String("store", envOr("STORE", ""), "override state store: true|false (default: config store.enabled, else on)")
3738
)
3839
flag.Parse()
@@ -55,6 +56,7 @@ func main() {
5556
h := proxy.New(pipe, cfg.NewStore(), agg, proxy.Options{
5657
OpenAIUpstream: *openai,
5758
AnthropicUpstream: *anthropic,
59+
BobUpstream: *bob, // enables the Bob gateway routes when set (BOB_UPSTREAM)
5860
// Gateway mode: real provider keys live here (eval-containers passes them
5961
// via env); the agent holds only a placeholder. Empty => pass client auth.
6062
OpenAIKey: os.Getenv("OPENAI_API_KEY"),

docs/integrations.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,45 @@ p := bifrost.New(pipe, store) // pipe from config.Build, store from config.New
8989
header / Anthropic `metadata.user_id`), else the content-hash fallback.
9090
- `PreLLMHook`/`PostLLMHook` are pass-throughs — the expand loop belongs in a transport wrapper
9191
(it must re-invoke upstream, which a hook cannot).
92+
93+
## Use it with an agent — Bob (BobShell)
94+
95+
IBM Bob is OpenAI-compatible via its `CUSTOM_BASE_URL`, but it calls **Bob-specific
96+
paths**: its model call is `POST /inference/v1/chat/completions`, and it makes
97+
control-plane calls (`GET /admin/v1/profile`, `/inference/v1/model/info`, …) that must
98+
reach the backend unmodified or the CLI won't boot. The proxy has an opt-in **Bob
99+
gateway** for exactly this shape — enable it with `BOB_UPSTREAM` (or `--bob-upstream`):
100+
101+
```sh
102+
BOB_UPSTREAM=https://api.us-east.bob.ibm.com \
103+
context-guru-proxy --preset balanced # any deterministic preset/config
104+
```
105+
106+
Then point Bob at the proxy and let it use its own key:
107+
108+
```sh
109+
CUSTOM_BASE_URL=http://localhost:4000/v1 \
110+
BOBSHELL_DEFAULT_AUTH_TYPE=custom \
111+
BOBSHELL_API_KEY=<your bob key> \
112+
bob --yolo "your task"
113+
```
114+
115+
How the gateway routes Bob's traffic:
116+
117+
- **Model calls** (`/inference/v1/chat/completions`) run through the pipeline like any
118+
OpenAI chat and are forwarded to the same path on `BOB_UPSTREAM`. Bob's own auth is
119+
passed straight through (no key injection).
120+
- **Control-plane calls** (everything else Bob hits) are proxied **verbatim** to
121+
`BOB_UPSTREAM`, so Bob authenticates and starts normally.
122+
123+
!!! tip "Start with deterministic components"
124+
A lossless, LLM-free pipeline (`format`, `toon`, `dedup`, `failed_run`, `cmdfilter`)
125+
needs no cheap-model config and leaves the transcript reversible. Verified end-to-end:
126+
with `[format, toon]` Bob authenticates and answers correctly through the proxy, with
127+
its model call reduced. For long Bob sessions, add `mask` (see [Choose a preset](../how-to/choose-a-preset.md)).
128+
129+
!!! note "Bob speaks its own backend protocol"
130+
Unlike Claude Code (`ANTHROPIC_BASE_URL`) or OpenAI-surface agents (`OPENAI_BASE_URL`),
131+
Bob's `CUSTOM_BASE_URL` points at the proxy **host**; Bob supplies the `/inference` and
132+
`/admin` paths itself. The gateway only activates when `BOB_UPSTREAM` is set, so it
133+
never changes behavior for the other integrations above.

proxy/proxy.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,14 @@ import (
3939
type Options struct {
4040
OpenAIUpstream string // e.g. https://api.openai.com
4141
AnthropicUpstream string
42-
OpenAIKey string // injected as Authorization: Bearer <key>
43-
AnthropicKey string // injected as x-api-key: <key>
42+
// BobUpstream, when set, enables the Bob (BobShell) gateway: Bob's
43+
// OpenAI-dialect model calls (POST /inference/v1/chat/completions) are reduced
44+
// and forwarded here, and every other path Bob calls (control-plane:
45+
// /admin/v1/profile, /inference/v1/model/info, …) is proxied through verbatim
46+
// so the CLI boots and authenticates. Point Bob's CUSTOM_BASE_URL at this proxy.
47+
BobUpstream string // e.g. https://api.us-east.bob.ibm.com
48+
OpenAIKey string // injected as Authorization: Bearer <key>
49+
AnthropicKey string // injected as x-api-key: <key>
4450
// ForceModel, when set, overwrites the request's "model" field. eval-containers
4551
// uses this to pin every call to EVAL_MODEL regardless of what the agent asked for.
4652
ForceModel string
@@ -100,9 +106,51 @@ func (h *Handler) Mux() *http.ServeMux {
100106
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) })
101107
m.HandleFunc("GET /stats", h.stats)
102108
m.HandleFunc("GET /expand", h.expand)
109+
// Bob (BobShell) gateway. Bob is OpenAI-compatible but calls Bob-specific
110+
// paths: its model call is POST /inference/v1/chat/completions (reduced like
111+
// any OpenAI chat), and its control-plane calls (/admin/v1/profile,
112+
// /inference/v1/model/info, …) must pass through verbatim so the CLI boots and
113+
// authenticates. The "/" catch-all is less specific than every route above, so
114+
// it only receives what nothing else matched. Enabled only when BobUpstream is
115+
// set, so default proxy behavior (unknown path => 404) is unchanged.
116+
if h.opts.BobUpstream != "" {
117+
m.HandleFunc("POST /inference/v1/chat/completions", h.chat(bschemas.OpenAI, upstream{
118+
base: h.opts.BobUpstream,
119+
path: "/inference/v1/chat/completions",
120+
// setKey nil: pass Bob's own auth (BOBSHELL key) straight through.
121+
}))
122+
m.HandleFunc("/", h.passthrough(h.opts.BobUpstream))
123+
}
103124
return m
104125
}
105126

127+
// passthrough transparently forwards a request to the Bob upstream unchanged —
128+
// for Bob's control-plane calls that must not be rewritten. Bob's own auth
129+
// header passes straight through (no key injection); the response is streamed
130+
// back as-is. Only the model route is reduced; everything else Bob calls lands
131+
// here and is proxied verbatim.
132+
func (h *Handler) passthrough(base string) http.HandlerFunc {
133+
return func(w http.ResponseWriter, r *http.Request) {
134+
body, _ := io.ReadAll(r.Body)
135+
target := base + r.URL.Path
136+
if r.URL.RawQuery != "" {
137+
target += "?" + r.URL.RawQuery
138+
}
139+
req, err := http.NewRequestWithContext(r.Context(), r.Method, target, strings.NewReader(string(body)))
140+
if err != nil {
141+
http.Error(w, "proxy: "+err.Error(), http.StatusBadGateway)
142+
return
143+
}
144+
copyHeaders(req.Header, r.Header)
145+
resp, err := h.client.Do(req)
146+
if err != nil {
147+
http.Error(w, "upstream: "+err.Error(), http.StatusBadGateway)
148+
return
149+
}
150+
h.stream(w, resp)
151+
}
152+
}
153+
106154
// compact runs the pipeline over the request body's messages and returns the
107155
// rewritten body — without forwarding upstream. This is the "compact a context,
108156
// hand it back" endpoint: a caller (e.g. the llm-d-router request-inline-

proxy/proxy_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,78 @@ func TestAnthropicRouteReducesToolResult(t *testing.T) {
129129
}
130130
}
131131

132+
// TestBobGatewayReducesModelAndPassesControlPlane drives the Bob (BobShell)
133+
// gateway: the OpenAI-dialect model call on /inference/v1/chat/completions is
134+
// reduced like any chat and forwarded to the same path, while a control-plane
135+
// call (GET /admin/v1/profile) passes through to the upstream verbatim.
136+
func TestBobGatewayReducesModelAndPassesControlPlane(t *testing.T) {
137+
type hit struct {
138+
method, path string
139+
body []byte
140+
}
141+
var hits []hit
142+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143+
b, _ := io.ReadAll(r.Body)
144+
hits = append(hits, hit{r.Method, r.URL.Path, b})
145+
w.Header().Set("Content-Type", "application/json")
146+
w.Write([]byte(`{"ok":true}`))
147+
}))
148+
defer upstream.Close()
149+
150+
cfg, err := config.LoadBytes([]byte("pipeline: [dedup]\ncomponents:\n dedup: {min_tokens: 20}\n"))
151+
if err != nil {
152+
t.Fatal(err)
153+
}
154+
agg := metrics.NewAggregator()
155+
pipe, err := cfg.Build(agg)
156+
if err != nil {
157+
t.Fatal(err)
158+
}
159+
h := proxy.New(pipe, store.NewMemory(store.Options{}), agg, proxy.Options{BobUpstream: upstream.URL})
160+
srv := httptest.NewServer(h.Mux())
161+
defer srv.Close()
162+
163+
// 1) Model call on Bob's path is reduced (dedup) and forwarded to the same path.
164+
dump := strings.Repeat("verbose repeated bob tool output line\n", 40)
165+
body := openAIBody(
166+
map[string]any{"role": "user", "content": "do it"},
167+
map[string]any{"role": "tool", "tool_call_id": "a", "content": dump},
168+
map[string]any{"role": "tool", "tool_call_id": "b", "content": dump},
169+
)
170+
resp, err := http.Post(srv.URL+"/inference/v1/chat/completions", "application/json", strings.NewReader(string(body)))
171+
if err != nil {
172+
t.Fatal(err)
173+
}
174+
resp.Body.Close()
175+
176+
// 2) Control-plane call is proxied through verbatim.
177+
cp, err := http.Get(srv.URL + "/admin/v1/profile")
178+
if err != nil {
179+
t.Fatal(err)
180+
}
181+
cp.Body.Close()
182+
183+
if len(hits) != 2 {
184+
t.Fatalf("want 2 upstream hits, got %d: %+v", len(hits), hits)
185+
}
186+
model, control := hits[0], hits[1]
187+
if model.path != "/inference/v1/chat/completions" {
188+
t.Fatalf("model call forwarded to wrong path: %q", model.path)
189+
}
190+
if !strings.Contains(gjson.GetBytes(model.body, "messages.2.content").String(), "identical to an earlier") {
191+
t.Fatalf("dedup did not run on the bob model call: %s", model.body)
192+
}
193+
if len(model.body) >= len(body) {
194+
t.Fatalf("bob model call not shrunk (before=%d after=%d)", len(body), len(model.body))
195+
}
196+
if control.method != "GET" || control.path != "/admin/v1/profile" {
197+
t.Fatalf("control-plane not passed through verbatim: %s %s", control.method, control.path)
198+
}
199+
if len(control.body) != 0 {
200+
t.Fatalf("control-plane GET should have empty body, got %d bytes", len(control.body))
201+
}
202+
}
203+
132204
func TestBypassHeaderForwardsUnchanged(t *testing.T) {
133205
var got []byte
134206
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)