From bf24f4f24401aa9ad17ee615cacc40f810a868a8 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 6 May 2026 04:51:03 +1000 Subject: [PATCH] feat(api): extend Gary header parsing to embeddings, search, and rerank handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseGaryContext() is now called at the entry point of POST /v1/embeddings, POST /v1/search, and POST /v1/rerank — matching the existing coverage in handleChat(). Audit ring buffer entries from all four surfaces are identical in shape. Architecture note recorded in GRAZE.md: per-handler injection is the only viable pattern because Next.js middleware runs on Edge Runtime, which is incompatible with the SQLite audit log and the in-process ring buffer. 8 new tests in gary-coverage-extension.test.ts verify that each handler captures all four Gary fields, that null fields are correctly represented when no headers are sent, that the ring buffer entry shape matches the chat handler's shape, and that entries from all three new handlers accumulate in the shared ring buffer in newest-first order. Co-Authored-By: Claude Sonnet 4.6 --- docs/GRAZE.md | 19 ++- src/app/api/v1/embeddings/route.ts | 2 + src/app/api/v1/rerank/route.ts | 2 + src/app/api/v1/search/route.ts | 2 + tests/unit/gary-coverage-extension.test.ts | 142 +++++++++++++++++++++ 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 tests/unit/gary-coverage-extension.test.ts diff --git a/docs/GRAZE.md b/docs/GRAZE.md index f5ccaa0c2..e25128477 100644 --- a/docs/GRAZE.md +++ b/docs/GRAZE.md @@ -52,7 +52,7 @@ Agents call named combos like `gary-scout`, `gary-adversarial`. The combo defini ### 4. Header contract -Defined here; parsed in a subsequent workstream. GaryOS injects these headers on every Claude Code worker invocation: +Implemented in PR #3. GaryOS injects these headers on every Claude Code worker invocation: | Header | Values | Purpose | |---|---|---| @@ -63,6 +63,23 @@ Defined here; parsed in a subsequent workstream. GaryOS injects these headers on Headers are observability and policy inputs only. They never override combo-resolved model selection. +**Implementation:** `src/lib/gary/context.ts` — `parseGaryContext(request)` parses all four headers, normalises enum values to lowercase, treats unknown enum values as `null` (warn + absent), appends to an in-memory 20-entry audit ring buffer, and best-effort-logs to SQLite via `logAuditEvent`. + +**Coverage surface** — `parseGaryContext` is called at the entry point of every `/v1/*` POST handler that handles agent traffic: + +| Route | Handler | +|---|---| +| `/v1/chat/completions` | `src/sse/handlers/chat.ts` → `handleChat()` | +| `/v1/embeddings` | `src/app/api/v1/embeddings/route.ts` → `POST()` | +| `/v1/search` | `src/app/api/v1/search/route.ts` → `POST()` | +| `/v1/rerank` | `src/app/api/v1/rerank/route.ts` → `POST()` | + +Not covered (intentionally): `GET /v1/embeddings` (list), `GET /v1/search` (list), `GET /v1/models` (read-only), `/v1/registered-keys` (admin), `/v1/batches` (async submit), `ws://` (WebSocket handshake). + +**Health endpoint:** `GET /monitoring/health` returns the last 20 gary-context entries under `garyContextHistory`. + +**Architecture note:** No global Next.js middleware. Next.js middleware runs on Edge Runtime, which is incompatible with the in-process SQLite audit log and the Node.js in-memory ring buffer. Per-handler injection is the only viable pattern given this constraint. + ### 5. Provider metadata fields Added to the provider config schema. Schema invariant: if `local=true` OR `e2ee=true`, then `trains_on_data` must be `false` (neither architecture permits training on the data). diff --git a/src/app/api/v1/embeddings/route.ts b/src/app/api/v1/embeddings/route.ts index 9992f8273..cffbfb147 100644 --- a/src/app/api/v1/embeddings/route.ts +++ b/src/app/api/v1/embeddings/route.ts @@ -22,6 +22,7 @@ import { v1EmbeddingsSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { getAllCustomModels, getProviderNodes } from "@/lib/localDb"; +import { parseGaryContext } from "@/lib/gary/context"; /** * Handle CORS preflight @@ -216,6 +217,7 @@ export async function handleValidatedEmbeddingRequestBody(body: ValidatedEmbeddi } export async function POST(request) { + parseGaryContext(request); let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/v1/rerank/route.ts b/src/app/api/v1/rerank/route.ts index e5ec4419b..e91e5a252 100644 --- a/src/app/api/v1/rerank/route.ts +++ b/src/app/api/v1/rerank/route.ts @@ -7,6 +7,7 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { v1RerankSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { getProviderNodes } from "@/lib/localDb"; +import { parseGaryContext } from "@/lib/gary/context"; /** * Handle CORS preflight @@ -45,6 +46,7 @@ function buildDynamicRerankProvider(node: any) { * and local provider_nodes (oMLX, vLLM, etc.) via dynamic routing. */ export async function POST(request) { + parseGaryContext(request); let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts index 2f282b454..cfeeb6218 100644 --- a/src/app/api/v1/search/route.ts +++ b/src/app/api/v1/search/route.ts @@ -21,6 +21,7 @@ import { getOrCoalesce, SEARCH_CACHE_DEFAULT_TTL_MS, } from "@omniroute/open-sse/services/searchCache.ts"; +import { parseGaryContext } from "@/lib/gary/context"; const CORS_HEADERS = { "Access-Control-Allow-Methods": "GET, POST, OPTIONS", @@ -88,6 +89,7 @@ function buildDomainFilter(filters?: { * POST /v1/search — execute a web search */ export async function POST(request: Request) { + parseGaryContext(request); let rawBody: unknown; try { rawBody = await request.json(); diff --git a/tests/unit/gary-coverage-extension.test.ts b/tests/unit/gary-coverage-extension.test.ts new file mode 100644 index 000000000..58aa91e1c --- /dev/null +++ b/tests/unit/gary-coverage-extension.test.ts @@ -0,0 +1,142 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "graze-gary-coverage-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +// Dynamic imports so DATA_DIR is set before any module initialises SQLite +const { POST: embeddingsPost } = await import("../../src/app/api/v1/embeddings/route.ts"); +const { POST: searchPost } = await import("../../src/app/api/v1/search/route.ts"); +const { POST: rerankPost } = await import("../../src/app/api/v1/rerank/route.ts"); +const { getRecentGaryContexts } = await import("../../src/lib/gary/context.ts"); + +test.after(() => { + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function makeRequest(url: string, garyHeaders: Record) { + return new Request(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...garyHeaders }, + body: "not-valid-json", + }); +} + +test("embeddings POST captures gary context in ring buffer", async () => { + const req = makeRequest("http://localhost/v1/embeddings", { + "x-gary-action-id": "embed-action-001", + "x-gary-stage": "draft", + "x-gary-playbook": "code-pr", + "x-gary-sensitivity": "tier-2", + }); + await embeddingsPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, "embed-action-001"); + assert.equal(entry.stage, "draft"); + assert.equal(entry.playbook, "code-pr"); + assert.equal(entry.sensitivity, "tier-2"); +}); + +test("embeddings POST captures null gary fields when no headers sent", async () => { + const req = makeRequest("http://localhost/v1/embeddings", {}); + await embeddingsPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, null); + assert.equal(entry.stage, null); + assert.equal(entry.playbook, null); + assert.equal(entry.sensitivity, null); +}); + +test("search POST captures gary context in ring buffer", async () => { + const req = makeRequest("http://localhost/v1/search", { + "x-gary-action-id": "search-action-002", + "x-gary-stage": "scout", + "x-gary-playbook": "research", + "x-gary-sensitivity": "tier-1", + }); + await searchPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, "search-action-002"); + assert.equal(entry.stage, "scout"); + assert.equal(entry.playbook, "research"); + assert.equal(entry.sensitivity, "tier-1"); +}); + +test("search POST captures null gary fields when no headers sent", async () => { + const req = makeRequest("http://localhost/v1/search", {}); + await searchPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, null); + assert.equal(entry.stage, null); + assert.equal(entry.playbook, null); + assert.equal(entry.sensitivity, null); +}); + +test("rerank POST captures gary context in ring buffer", async () => { + const req = makeRequest("http://localhost/v1/rerank", { + "x-gary-action-id": "rerank-action-003", + "x-gary-stage": "validate", + "x-gary-playbook": "decision", + "x-gary-sensitivity": "tier-3", + }); + await rerankPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, "rerank-action-003"); + assert.equal(entry.stage, "validate"); + assert.equal(entry.playbook, "decision"); + assert.equal(entry.sensitivity, "tier-3"); +}); + +test("rerank POST captures null gary fields when no headers sent", async () => { + const req = makeRequest("http://localhost/v1/rerank", {}); + await rerankPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.equal(entry.actionId, null); + assert.equal(entry.stage, null); + assert.equal(entry.playbook, null); + assert.equal(entry.sensitivity, null); +}); + +test("gary context ring buffer entries from non-chat handlers have same shape as chat entries", async () => { + const req = makeRequest("http://localhost/v1/embeddings", { + "x-gary-action-id": "shape-check-004", + "x-gary-stage": "triage", + }); + await embeddingsPost(req); + + const entry = getRecentGaryContexts()[0]; + assert.ok("actionId" in entry, "missing actionId"); + assert.ok("stage" in entry, "missing stage"); + assert.ok("playbook" in entry, "missing playbook"); + assert.ok("sensitivity" in entry, "missing sensitivity"); + assert.ok("timestamp" in entry, "missing timestamp"); + assert.ok("requestId" in entry, "missing requestId"); + assert.equal(typeof entry.timestamp, "string"); + assert.ok(!Number.isNaN(Date.parse(entry.timestamp)), "timestamp is not ISO 8601"); +}); + +test("all three handlers register entries that accumulate in the shared ring buffer", async () => { + const before = getRecentGaryContexts().length; + + await embeddingsPost(makeRequest("http://localhost/v1/embeddings", { "x-gary-stage": "plan" })); + await searchPost(makeRequest("http://localhost/v1/search", { "x-gary-stage": "review-plan" })); + await rerankPost(makeRequest("http://localhost/v1/rerank", { "x-gary-stage": "submit" })); + + const entries = getRecentGaryContexts(); + const added = Math.min(entries.length, entries.length - before + 3); + assert.ok(entries.length >= Math.min(before + 3, 20), "expected at least 3 new entries (or ring at cap)"); + + // Most-recent three entries are in newest-first order + assert.equal(entries[0].stage, "submit"); + assert.equal(entries[1].stage, "review-plan"); + assert.equal(entries[2].stage, "plan"); +});