@gr8ful/spf 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +8 -0
  2. package/assets/skill/references/config.md +1 -0
  3. package/dist/cli/commands/doctor.js +25 -3
  4. package/dist/cli/commands/estimate.d.ts +22 -6
  5. package/dist/cli/commands/estimate.js +32 -10
  6. package/dist/cli/commands/loop.d.ts +20 -0
  7. package/dist/cli/commands/loop.js +20 -1
  8. package/dist/cli/commands/ui.js +2 -1
  9. package/dist/cli/commands/watch.js +1 -1
  10. package/dist/cli/index.js +2 -2
  11. package/dist/cli/interview.js +13 -0
  12. package/dist/cli/ui/run_dashboard.js +13 -7
  13. package/dist/core/agent_cc.d.ts +19 -3
  14. package/dist/core/agent_cc.js +38 -18
  15. package/dist/core/agent_flue.js +51 -14
  16. package/dist/core/agent_opencode.d.ts +62 -25
  17. package/dist/core/agent_opencode.js +71 -30
  18. package/dist/core/agents.d.ts +51 -4
  19. package/dist/core/agents.js +79 -4
  20. package/dist/core/console.d.ts +24 -4
  21. package/dist/core/console.js +20 -7
  22. package/dist/core/data_types.d.ts +300 -19
  23. package/dist/core/data_types.js +134 -5
  24. package/dist/core/issues/jira_provider.d.ts +51 -1
  25. package/dist/core/issues/jira_provider.js +69 -1
  26. package/dist/core/issues/provider.d.ts +23 -0
  27. package/dist/core/loop.d.ts +39 -1
  28. package/dist/core/loop.js +33 -2
  29. package/dist/core/ollama_provider.d.ts +96 -13
  30. package/dist/core/ollama_provider.js +172 -26
  31. package/dist/core/otel.js +10 -1
  32. package/dist/core/otel_propagation.d.ts +168 -24
  33. package/dist/core/otel_propagation.js +219 -43
  34. package/dist/core/permissions.d.ts +16 -1
  35. package/dist/core/permissions.js +91 -3
  36. package/dist/core/providers.js +8 -3
  37. package/dist/core/refine.js +13 -1
  38. package/dist/core/runner.d.ts +33 -2
  39. package/dist/core/runner.js +40 -5
  40. package/dist/core/tiering.js +7 -3
  41. package/dist/core/tracer.d.ts +7 -1
  42. package/dist/core/tracer.js +15 -3
  43. package/dist/ui/server/db.d.ts +8 -1
  44. package/dist/ui/server/db.js +21 -4
  45. package/dist/ui/server/serve.d.ts +7 -0
  46. package/dist/ui/server/serve.js +10 -7
  47. package/dist/ui/shared/types.d.ts +16 -0
  48. package/package.json +1 -1
@@ -41,6 +41,9 @@
41
41
  * `type` imports below are erased at compile time (verbatimModuleSyntax) and
42
42
  * cost nothing at runtime either way.
43
43
  */
44
+ import { context as contextApi, isSpanContextValid, trace as traceApi } from "@opentelemetry/api";
45
+ import { isFluePropagationInstalled, X_CORRELATION_ID_HEADER, X_SPF_AGENT_HEADER } from "./otel_propagation.js";
46
+ import { newId } from "./utils.js";
44
47
  // Ollama has no auth of its own — `pi-ai`'s auth resolution always calls
45
48
  // `getClientApiKey()` before a dispatch, and that call throws "No API key
46
49
  // for provider: ollama" if the resolved key is falsy (verified live: the
@@ -51,6 +54,65 @@
51
54
  // sends anywhere Ollama would look at it: Ollama's OpenAI-compatible server
52
55
  // does not check the Authorization header's contents.
53
56
  const DUMMY_API_KEY = "ollama-local-unused";
57
+ /**
58
+ * `OLLAMA_API_KEY`, trimmed, when the operator has set one — e.g. the
59
+ * Briefs gateway (Envoy AI Gateway) enforces a per-client bearer and 401s
60
+ * the dummy key `DUMMY_API_KEY` was designed for a bare local server that
61
+ * checks nothing. Falls back to the dummy exactly as before when unset, so
62
+ * THIS function's own return value — the resolved `Authorization` bearer —
63
+ * is byte-identical to pre-gateway behavior for a bare local Ollama server
64
+ * (the common case this module was built for). MINOR-H: that is narrower
65
+ * than "the whole request is unchanged" — it is not, even with
66
+ * `OLLAMA_API_KEY` unset: a `traceparent` (and, once `agent_flue.ts` has an
67
+ * adw_id/agent_name to give it, `x-correlation-id`/`x-spf-agent`) is ALWAYS
68
+ * sent now, gateway or no gateway (see the "Gateway headers" section below).
69
+ * A bare local Ollama server ignores headers it doesn't recognize, so this
70
+ * is harmless — just not byte-identical. Read fresh inside `resolve()` (see
71
+ * its call site below) — never cached — so a key exported mid-process (or
72
+ * changed) takes effect on the very next dispatch with no re-registration.
73
+ *
74
+ * MINOR 3: exported so `doctor.ts`'s `OLLAMA_BASE_URL reachability` probe
75
+ * calls this SAME function rather than reading `process.env.OLLAMA_API_KEY`
76
+ * raw — a prior version of that probe sent NO `Authorization` header at all
77
+ * when the env var was unset, which diverges from what a real dispatch
78
+ * sends (the dummy bearer below, always). Against a gateway that rejects a
79
+ * request with no `Authorization` header at all differently than one with a
80
+ * wrong/dummy bearer, that divergence could make doctor report reachable
81
+ * when a real dispatch would 401, or vice versa. Calling `ollamaApiKey()` in
82
+ * both places means doctor's probe and a real dispatch send byte-identical
83
+ * bearers for the same env state.
84
+ */
85
+ export function ollamaApiKey() {
86
+ const key = (process.env.OLLAMA_API_KEY ?? "").trim();
87
+ return key || DUMMY_API_KEY;
88
+ }
89
+ /** Must NEVER be sent — Envoy/Switchyard own it end-to-end; a client-supplied value breaks their sampling. Exported only so tests can assert its absence by name, not a literal string. The sole surviving export of this name in the codebase — see MINOR-G in this change's review; `otel_propagation.ts` no longer has one now that `XRequestIdPropagator` is gone (BLOCKER B). */
90
+ export const X_REQUEST_ID_HEADER = "x-request-id";
91
+ /**
92
+ * A fresh W3C `traceparent` for one outbound call — used only on the NOT
93
+ * INSTALLED path (see the section above); when propagation IS installed,
94
+ * `resolve()` does not call this at all, relying entirely on the
95
+ * instrumentation's own per-request injection instead (this is what fixed
96
+ * BLOCKER A/MAJOR-C: this function used to be called unconditionally, and
97
+ * on the installed path it silently reused the one still-open span's
98
+ * traceparent for every call inside that span, which is both a duplicate
99
+ * header AND not actually fresh per call).
100
+ *
101
+ * Reuses the active OTel span context when one is installed and current —
102
+ * the same trace this call's other telemetry already belongs to — falling
103
+ * back to a brand-new random trace/span id pair when there is none (no span
104
+ * active at this exact point, e.g. a stray call before any span opened), so
105
+ * the gateway still gets a well-formed, per-call-unique traceparent either
106
+ * way. Never throws; `isSpanContextValid` is the same guard
107
+ * `otel_propagation.ts`'s own propagators use.
108
+ */
109
+ export function freshTraceparent() {
110
+ const active = traceApi.getSpanContext(contextApi.active());
111
+ if (active && isSpanContextValid(active)) {
112
+ return `00-${active.traceId}-${active.spanId}-01`;
113
+ }
114
+ return `00-${newId(32)}-${newId(16)}-01`;
115
+ }
54
116
  // Advisory only: pi-ai's `openai-completions` api reads this per REQUEST via
55
117
  // its own `options.maxTokens`, not from `Model.maxTokens` directly — the
56
118
  // field here only feeds Flue's compaction-reserve sizing (moot in practice
@@ -71,14 +133,25 @@ const DEFAULT_MAX_TOKENS = 8192;
71
133
  *
72
134
  * `OLLAMA_BASE_URL` is read fresh (via `ollamaBaseUrl()`) at each
73
135
  * registration call, and the whole union is re-registered at whatever URL
74
- * is current AT THAT MOMENT so a mid-process env change applies unevenly:
75
- * ids already registered keep the base URL they were registered under until
76
- * the NEXT new id triggers a fresh union re-registration, which then
77
- * re-points every id at once. Deliberate: a single local server for the
78
- * whole process is the supported case, and this asymmetry only bites a
79
- * per-agent override, which isn't.
136
+ * is current AT THAT MOMENT. `registerOllamaModel` re-runs this rebuild on
137
+ * EVERY call now (see its own doc for why: MAJOR-D's gateway-header
138
+ * re-stamping needs it), so in practice a mid-process `OLLAMA_BASE_URL`
139
+ * change is picked up by the very next dispatch to ANY already-registered
140
+ * id, not just the next brand-new one.
80
141
  */
81
142
  const registeredIds = new Set();
143
+ /**
144
+ * The `GatewayCallContext` each model id was MOST RECENTLY registered with
145
+ * — see the "Gateway headers" section above for why `x-correlation-id`/
146
+ * `x-spf-agent` are static per-model (when otel propagation isn't
147
+ * installed) rather than resolved per-call. Keyed by model id so a union
148
+ * re-registration (triggered by ANY registration call, new id or repeat —
149
+ * see `registerOllamaModel`'s MAJOR-D doc) can rebuild every
150
+ * already-registered id's `Model.headers` from the context it MOST
151
+ * RECENTLY got, rather than dropping it or freezing it at first
152
+ * registration.
153
+ */
154
+ const registrationContext = new Map();
82
155
  // Registrations currently in flight, keyed by model id — lets a second
83
156
  // caller for the SAME id that arrives before the first `await` resolves
84
157
  // join that in-progress registration instead of returning immediately with
@@ -96,7 +169,25 @@ export function ollamaBaseUrl() {
96
169
  const raw = (process.env.OLLAMA_BASE_URL ?? "").trim();
97
170
  return raw || "http://localhost:11434/v1";
98
171
  }
99
- function modelFor(id, baseUrl) {
172
+ function modelFor(id, baseUrl, ctx) {
173
+ // Static per-model headers — see the "Gateway headers" section above for
174
+ // why `x-correlation-id`/`x-spf-agent` live here rather than in
175
+ // `resolve()`, and ONLY on the NOT INSTALLED path: when
176
+ // `isFluePropagationInstalled()` is true, `GatewayHeadersPropagator`
177
+ // already injects both per real request, correctly attributed per
178
+ // session — stamping them here too would double them up on the wire
179
+ // (BLOCKER A's bug, for these two headers instead of `traceparent`).
180
+ // Omitted entirely (no `headers` key at all) when there is nothing to
181
+ // stamp — propagation installed, or `ctx` absent/empty — so a caller that
182
+ // never passes one, or a run with otel configured, gets a `Model` with no
183
+ // static headers.
184
+ const staticHeaders = {};
185
+ if (!isFluePropagationInstalled()) {
186
+ if (ctx?.adwId)
187
+ staticHeaders[X_CORRELATION_ID_HEADER] = ctx.adwId;
188
+ if (ctx?.agentName)
189
+ staticHeaders[X_SPF_AGENT_HEADER] = ctx.agentName;
190
+ }
100
191
  return {
101
192
  id,
102
193
  name: id,
@@ -113,30 +204,55 @@ function modelFor(id, baseUrl) {
113
204
  // silently truncating requests.
114
205
  contextWindow: 0,
115
206
  maxTokens: DEFAULT_MAX_TOKENS,
207
+ ...(Object.keys(staticHeaders).length > 0 ? { headers: staticHeaders } : {}),
116
208
  };
117
209
  }
118
210
  /**
119
211
  * Registers `modelId` (the part after `ollama/` in an agent's `model`
120
212
  * config) with Flue's provider registry, alongside every other `ollama/*`
121
- * id ever registered this process. Idempotent: a repeat of an already-seen
122
- * id is a no-op no re-registration, no re-import. A concurrent call for
123
- * the SAME id joins the in-flight registration rather than returning early
124
- * (see `inflight`'s doc); `registeredIds` itself is only ever updated AFTER
125
- * `setProvider()` succeeds, so a failed attempt (a bad install, a bundler
126
- * that can't resolve the deep `.lazy` subpath, a future validation error)
127
- * leaves the id unregistered and eligible for a real retry — not
128
- * permanently and misleadingly marked "done" while nothing is actually
129
- * registered.
213
+ * id ever registered this process. NOT idempotent w.r.t. `ctx` (see MAJOR-D
214
+ * below) every call re-runs the union re-registration (dynamic imports
215
+ * are cheap after the first, and `setProvider()` is a cheap in-memory
216
+ * upsert), so this id's `Model.headers` always reflect the MOST RECENT
217
+ * `ctx` this function was called with, not just the first. A concurrent
218
+ * call for the SAME id joins the in-flight registration rather than running
219
+ * a second one in parallel (see `inflight`'s doc); `registeredIds` itself
220
+ * is only ever updated AFTER `setProvider()` succeeds, so a failed attempt
221
+ * (a bad install, a bundler that can't resolve the deep `.lazy` subpath, a
222
+ * future validation error) leaves the id unregistered and eligible for a
223
+ * real retry — not permanently and misleadingly marked "done" while
224
+ * nothing is actually registered.
130
225
  *
131
226
  * Must complete before the FIRST Flue dispatch that names this model
132
227
  * (agent_flue.ts's `run()` awaits this before `ensureRuntime()`/`start()`),
133
- * but is equally safe to call again later with a new id mid-process that
134
- * later call's union re-registration is exactly how a second model gets
135
- * added without orphaning the first (see the `registeredIds` doc above).
228
+ * but is equally safe to call again later with a new id, or the SAME id
229
+ * again, mid-process — a new id's union re-registration is how a second
230
+ * model gets added without orphaning the first (see the `registeredIds` doc
231
+ * above); a repeat of the SAME id is how MAJOR-D below is fixed.
232
+ *
233
+ * MAJOR-D (fixed): `ctx`, when given AND `isFluePropagationInstalled()` is
234
+ * false (see the "Gateway headers" section above — when it's true, these
235
+ * two headers come from the per-request `GatewayHeadersPropagator`
236
+ * instead), is stamped onto this id's `Model.headers` as
237
+ * `x-correlation-id`/`x-spf-agent`. A PRIOR version of this function
238
+ * returned immediately for an already-registered id (a false comment
239
+ * claimed "one spf process runs one adw_id for its whole lifetime" to
240
+ * justify this) — which meant every later agent/adw_id sharing a model id
241
+ * within one process (spf `loop`/`fanout`/`watch`, which run many adw_ids
242
+ * in ONE process, `fanout` concurrently) silently kept the FIRST
243
+ * registration's headers forever. Re-running the full registration on every
244
+ * call, unconditionally, fixes that for every case except one, which
245
+ * remains and is not silently swallowed: two flue agents dispatching
246
+ * CONCURRENTLY (not sequentially) to the SAME `ollama/<id>` model id race on
247
+ * `registrationContext`/`setProvider()` — whichever registration's
248
+ * `setProvider()` call lands last wins the headers BOTH calls' subsequent
249
+ * dispatches see, until the next registration for that id. This is a
250
+ * `fanout` concurrency > 1 scenario specifically (two DIFFERENT agents,
251
+ * same process, same model id, truly overlapping registrations) — a
252
+ * sequential loop/watch never hits it, since each call's `await` completes
253
+ * before the next one starts.
136
254
  */
137
- export async function registerOllamaModel(modelId) {
138
- if (registeredIds.has(modelId))
139
- return;
255
+ export async function registerOllamaModel(modelId, ctx) {
140
256
  const existing = inflight.get(modelId);
141
257
  if (existing)
142
258
  return existing;
@@ -156,8 +272,9 @@ export async function registerOllamaModel(modelId) {
156
272
  // AFTER `setProvider()` below succeeds (see this function's doc).
157
273
  const ids = new Set(registeredIds);
158
274
  ids.add(modelId);
275
+ registrationContext.set(modelId, ctx ?? {});
159
276
  const baseUrl = ollamaBaseUrl();
160
- const models = [...ids].map((id) => modelFor(id, baseUrl));
277
+ const models = [...ids].map((id) => modelFor(id, baseUrl, registrationContext.get(id)));
161
278
  const options = {
162
279
  id: "ollama",
163
280
  name: "Ollama (local)",
@@ -165,9 +282,37 @@ export async function registerOllamaModel(modelId) {
165
282
  auth: {
166
283
  apiKey: {
167
284
  name: "Ollama (keyless)",
168
- // See DUMMY_API_KEY above for why this can't just report "no key
169
- // needed" pi-ai's dispatch path requires a truthy resolved key.
170
- resolve: async () => ({ auth: { apiKey: DUMMY_API_KEY } }),
285
+ // Fresh per real dispatch (pi-ai reinvokes `resolve()` on every
286
+ // `Models.stream()`/`applyAuth()` call, never caching it see the
287
+ // "Gateway headers" section above) `apiKey` honors a real
288
+ // `OLLAMA_API_KEY` when the operator set one (e.g. the Briefs
289
+ // gateway's per-client bearer), falling back to the DUMMY_API_KEY
290
+ // a bare keyless local server needs (see its own doc for why that
291
+ // can't just be "no key needed" instead).
292
+ //
293
+ // `headers.traceparent` is minted here ONLY when
294
+ // `isFluePropagationInstalled()` is false — checked fresh on every
295
+ // call, since propagation can be installed partway through this
296
+ // process's lifetime (the first ollama dispatch in a run with
297
+ // otel configured registers the model BEFORE
298
+ // `installFluePropagation()` runs — see `agent_flue.ts`'s `run()`
299
+ // — so a later dispatch on the SAME already-registered model must
300
+ // still re-check, not trust a value baked in at registration
301
+ // time). When installed, `@opentelemetry/instrumentation-undici`
302
+ // already injects a real, fresh `traceparent` for this exact
303
+ // outbound request (see `otel_propagation.ts`); minting a second
304
+ // one here would put TWO `traceparent` headers on the wire
305
+ // (`UndiciInstrumentation` appends, it does not replace) — this
306
+ // was BLOCKER A. `x-correlation-id`/`x-spf-agent` are NEVER set
307
+ // here either way — see `Model.headers` above (not installed) and
308
+ // `GatewayHeadersPropagator` (installed) for where those two
309
+ // actually come from.
310
+ resolve: async () => ({
311
+ auth: {
312
+ apiKey: ollamaApiKey(),
313
+ ...(isFluePropagationInstalled() ? {} : { headers: { traceparent: freshTraceparent() } }),
314
+ },
315
+ }),
171
316
  },
172
317
  },
173
318
  models,
@@ -205,4 +350,5 @@ export function resetOllamaRegistrationForTest() {
205
350
  registeredIds.clear();
206
351
  inflight.clear();
207
352
  lastProvider = undefined;
353
+ registrationContext.clear();
208
354
  }
package/dist/core/otel.js CHANGED
@@ -407,7 +407,15 @@ export function redact(message, secrets) {
407
407
  function numOrNull(value) {
408
408
  return typeof value === "number" && Number.isFinite(value) ? value : null;
409
409
  }
410
- /** UsageBreakdown's token fields -> attribute suffixes. Numbers only, by construction. */
410
+ /**
411
+ * UsageBreakdown's token fields -> attribute suffixes. Numbers only, by
412
+ * construction. `billable_tokens` rides alongside `total_tokens` — the SAME
413
+ * split `run_dashboard.tsx`'s live spend line and `estimate.ts`'s cutoff
414
+ * projection now both carry (see `UsageBreakdown.billable_tokens`'s doc
415
+ * comment in `data_types.ts`) — so a Langfuse/OTEL consumer graphing spend
416
+ * against `defaults.max_run_tokens` has the metric the real ceiling check
417
+ * actually uses, not just the display total (cache reads included).
418
+ */
411
419
  const TOKEN_FIELDS = [
412
420
  ["input_tokens", "spf.tokens.input"],
413
421
  ["output_tokens", "spf.tokens.output"],
@@ -415,6 +423,7 @@ const TOKEN_FIELDS = [
415
423
  ["cache_write_tokens", "spf.tokens.cache_write"],
416
424
  ["reasoning_tokens", "spf.tokens.reasoning"],
417
425
  ["total_tokens", "spf.tokens.total"],
426
+ ["billable_tokens", "spf.tokens.billable"],
418
427
  ];
419
428
  const COST_FIELDS = [
420
429
  ["input_cost", "spf.cost.input"],
@@ -14,8 +14,11 @@
14
14
  * `-undici` create a real client span (with a real, non-noop SpanContext)
15
15
  * around every outbound `http`/`https`/`fetch`(undici) call made from this
16
16
  * process, and the OTel API's global propagator is what those
17
- * instrumentations use to inject `traceparent` (and, here, `x-request-id`)
18
- * into that call's headers REGARDLESS of which provider SDK issued it.
17
+ * instrumentations use to inject `traceparent` (and, here, the gateway's own
18
+ * `x-correlation-id`/`x-spf-agent`see GatewayHeadersPropagator below;
19
+ * NEVER `x-request-id`, which Envoy/Switchyard own end-to-end and which a
20
+ * client-sent value would corrupt) into that call's headers — REGARDLESS of
21
+ * which provider SDK issued it.
19
22
  * This reaches every provider whose Node SDK issues requests through
20
23
  * Node's own `http`/`https` modules or `undici` (verified: `fetch()`,
21
24
  * `https.request()`). It does NOT reach a provider transport that bypasses
@@ -58,7 +61,64 @@
58
61
  * agent processing", which is the id SPF mints and hands to
59
62
  * `init(SfAgent, { id })`. Extraction goes through the globally
60
63
  * registered propagator against ROOT_CONTEXT, so no leaked loop context
61
- * can stick. Consequences, all intended:
64
+ * can stick.
65
+ *
66
+ * BLOCKER 1 (fixed) — `resolveRootContext`'s return value NEVER becomes the
67
+ * active context. Verified against `@flue/opentelemetry`'s own dist
68
+ * (index.mjs:361 for the `chat <model>` span, :329 for the interceptor that
69
+ * activates it): the resolved `Context` is passed to `tracer.startSpan(...)`
70
+ * ONLY as `parentContext` — it supplies the new span's trace id/parent span
71
+ * id and is then discarded. What actually gets activated around the real
72
+ * dispatch is `context.with(trace.setSpan(context.active(), span), next)` —
73
+ * a context built from `context.active()` (whatever was active before,
74
+ * almost always ROOT) plus the freshly minted `span`, NOT the resolved
75
+ * context itself. A prior version of this module stashed the session's
76
+ * `adw_id`/`agent_name` as a plain context VALUE on the context
77
+ * `resolveFlueRootContext` returned (keyed under a private context key) and
78
+ * had `GatewayHeadersPropagator` read that value back at inject time — which
79
+ * can never work, because that exact context object is never the one made
80
+ * active; only its SpanContext (traceId/spanId/flags) survives, carried by
81
+ * the new span. Confirmed live: `scratchpad/probe_installed_real.mjs`,
82
+ * reproducing flue's exact two-line sequence against a real pi-ai dispatch,
83
+ * printed `x-correlation-id: ABSENT` / `x-spf-agent: ABSENT` before this fix.
84
+ *
85
+ * THE FIX: key the registration by TRACE ID instead of by context identity.
86
+ * `registerFlueSessionTrace` now ALSO records, in `traceIdRegistrations`,
87
+ * the trace id parsed out of the very `traceparent` it's given — the same
88
+ * deterministic trace id `resolveFlueRootContext` extracts and hands to
89
+ * `tracer.startSpan` as that span's parent, so the span it creates carries
90
+ * that exact trace id forward into whatever context DOES get activated.
91
+ * `GatewayHeadersPropagator.inject()` below reads `trace.getSpanContext(ctx)
92
+ * ?.traceId` — the thing that provably survives into the active context at
93
+ * request time — and looks IT UP in `traceIdRegistrations`, rather than
94
+ * trying to read a value off a context object that was never activated.
95
+ * One deterministic trace id per adw_id (`otel.ts:556`'s `traceIdFor(adwId)`,
96
+ * reused for EVERY agent call in that run — `otel.ts:737`'s
97
+ * `agentCallTraceContext` varies only the span id, never the trace id), and
98
+ * `traceIdRegistrations` is keyed on that trace id alone, not on session id
99
+ * or span id. So "latest registration for this trace id wins" is exact
100
+ * within a run when its agents run one at a time — which is how a `fanout`
101
+ * attempt's own agents run: `fanout.ts:448` derives each attempt its OWN
102
+ * adw_id, so distinct fanout attempts get distinct trace ids and cannot
103
+ * collide here, regardless of `fanout`'s concurrency. The one real collision
104
+ * this map can still see is narrower and does not happen in SPF today: TWO
105
+ * AGENTS OVERLAPPING INSIDE ONE adw_id — a single run dispatching a second
106
+ * flue agent call before the first one's `unregisterFlueSessionTrace` has
107
+ * run — since both share that run's one trace id, the second
108
+ * `registerFlueSessionTrace` call overwrites the first agent's entry in
109
+ * `traceIdRegistrations` while its dispatch may still be in flight, and that
110
+ * agent's outbound request would then carry the OTHER agent's
111
+ * `x-correlation-id`/`x-spf-agent` (never a wrong `adw_id`, since both
112
+ * belong to the same run — only the wrong `agentName`). No chain SPF ships
113
+ * dispatches two agents concurrently within one adw_id; this is flagged as
114
+ * the mechanism's honest limit, not a bug being carried forward. The SAME
115
+ * registration carries this session's `adw_id`/agent name (see
116
+ * `FlueSessionRegistration`) so a
117
+ * session's `x-correlation-id`/`x-spf-agent` ride the exact same "resolved
118
+ * once per submission, read per real HTTP call" path as `traceparent` does,
119
+ * via `GatewayHeadersPropagator` below, instead of the static-per-model-id
120
+ * fallback `ollama_provider.ts` needs when this pipeline isn't installed at
121
+ * all (otel unconfigured). Consequences, all intended:
62
122
  * - Flue's spans inherit SPF's sha256 trace id, parented under the
63
123
  * right agent-call span PER SESSION — correct under multiple agents
64
124
  * per process, concurrent agents, and claim-loop restarts alike. Span
@@ -66,17 +126,42 @@
66
126
  * - The http/undici client spans' injected `traceparent` carries the
67
127
  * deterministic id too, so Switchyard/vLLM hops land as descendants of
68
128
  * SPF's trace — parity with `claude_code`'s `ANTHROPIC_CUSTOM_HEADERS`
69
- * path. `x-request-id` stays the this-span id for request-keyed
70
- * correlation, unchanged.
129
+ * path. The same per-request injection point also carries THIS
130
+ * session's `x-correlation-id`/`x-spf-agent`, correctly attributed even
131
+ * when multiple sessions are concurrent in one process, AS LONG AS their
132
+ * deterministic trace ids differ (their `ctx.id`s, and therefore their
133
+ * registrations, are distinct) — see BLOCKER 1 above for the precise
134
+ * lookup key.
71
135
  * - Unmapped sessions (never registered, restarted process with a
72
136
  * durable backlog, post-`unregister` straggler bookkeeping spans)
73
137
  * resolve to an unparented root — flue's spans root a separate SDK
74
- * trace exactly as v1 did, correlatable by `x-request-id`/`spf.adw_id`/
75
- * time window. Degraded join, never an error and never MIS-attributed.
138
+ * trace exactly as v1 did, correlatable by `spf.adw_id`/time window.
139
+ * Degraded join, never an error and never MIS-attributed; the gateway
140
+ * headers for exactly this case are simply ABSENT (no fallback identity
141
+ * — see `GatewayHeadersPropagator.inject()` below) rather than guessing
142
+ * at whichever session happened to register most recently.
76
143
  * - flue's internal `executionContext.traceCarrier` (typed but not on
77
144
  * the public `AgentDispatchRequest` surface) stays unused — noted here
78
145
  * as flue's own escape hatch, not something SPF reaches into.
79
146
  *
147
+ * STATIC HEADERS STAY SUPPRESSED WHEN INSTALLED (BLOCKER 1's other half,
148
+ * decided AGAINST enabling): `ollama_provider.ts`'s `modelFor()` keeps
149
+ * omitting `Model.headers`' `x-correlation-id`/`x-spf-agent` whenever
150
+ * `isFluePropagationInstalled()` is true — it is NOT also stamped as a
151
+ * baseline alongside this propagator. Measured live
152
+ * (`scratchpad/probe_duplicate_header.mjs`, against the REAL
153
+ * `@opentelemetry/instrumentation-undici` used here): `request.addHeader(k,
154
+ * v)` — what that instrumentation calls for every header
155
+ * `propagation.inject()` returns — APPENDS a second raw header line rather
156
+ * than replacing one already present (undici's own `Request.addHeader` has
157
+ * no dedupe-by-name step); the receiving `http.IncomingMessage.headers`
158
+ * then comma-joins the two into one corrupted value
159
+ * (`"FROM_STATIC, FROM_PROPAGATOR"`), and `req.rawHeaders` shows the literal
160
+ * duplicate line. So when propagation is installed, THIS propagator is the
161
+ * sole source of `x-correlation-id`/`x-spf-agent` — never doubled up with a
162
+ * static value from `Model.headers` — proven on the wire by
163
+ * `src/test/ollama_gateway_e2e.test.ts`'s "no duplicate headers" test.
164
+ *
80
165
  * REGISTRATION TIMING. `installFluePropagation()` is called from
81
166
  * `agent_flue.ts`'s `run()`, before `ensureRuntime()`/dispatch — i.e. before
82
167
  * the actual outbound call, which is the only ordering that matters for
@@ -98,33 +183,73 @@
98
183
  * agent dispatches this process makes) happens to arrive first.
99
184
  */
100
185
  import { type Context, type TextMapPropagator, type TextMapSetter } from "@opentelemetry/api";
101
- /** The one custom propagation field this repo adds beyond the standard W3C `traceparent`: the current span's own id, for a collector/log pipeline that correlates by request rather than by trace. */
102
- export declare const X_REQUEST_ID_HEADER = "x-request-id";
103
186
  /**
104
- * Injects `x-request-id` from whatever span is active at the point of the
105
- * outbound call the same id that would appear as that span's `spanId` on
106
- * the (separate — see the module header) Flue-side trace. One-directional:
107
- * `extract()` is a pass-through, since nothing on the INBOUND side of an
108
- * outbound provider call needs to read this back.
187
+ * The Briefs gateway's own correlation headers see `ollama_provider.ts`'s
188
+ * "Gateway headers" section for the full three-header contract (the third,
189
+ * `traceparent`, is the W3C standard one `W3CTraceContextPropagator` already
190
+ * injects). Defined here (not in `ollama_provider.ts`) because
191
+ * `GatewayHeadersPropagator` below is the thing that actually injects them
192
+ * onto the wire when this pipeline is installed; `ollama_provider.ts`
193
+ * imports these two constants rather than redeclaring them, so there is
194
+ * exactly one spelling of each header name in the codebase.
195
+ */
196
+ export declare const X_CORRELATION_ID_HEADER = "x-correlation-id";
197
+ export declare const X_SPF_AGENT_HEADER = "x-spf-agent";
198
+ /**
199
+ * One flue session's (`ctx.id`'s) registration: the W3C `traceparent` string
200
+ * for SPF's deterministic agent-call span (see `registerFlueSessionTrace`),
201
+ * plus this session's `adw_id`/agent name — carried the SAME way, see the
202
+ * module header's FLUE SPANS JOIN... section for why both ride one
203
+ * registration rather than two separate maps.
204
+ */
205
+ export interface FlueSessionRegistration {
206
+ traceparent: string;
207
+ adwId?: string;
208
+ agentName?: string;
209
+ }
210
+ /**
211
+ * Registers `registration` (SPF's deterministic agent-call traceparent, plus
212
+ * this session's adw_id/agent name — see `FlueSessionRegistration`) as the
213
+ * trace root for flue spans belonging to `sessionId` — flue's instance id,
214
+ * minted by SPF and handed to `init(SfAgent, { id })`. Overwrites a prior
215
+ * registration for the same id (a same-phase retry is the same logical call;
216
+ * the current call wins). Also indexes the SAME identity under this
217
+ * registration's parsed trace id (`traceIdRegistrations`) — see the module
218
+ * header's BLOCKER 1 section and that map's own doc for why.
109
219
  */
110
- export declare class XRequestIdPropagator implements TextMapPropagator {
220
+ export declare function registerFlueSessionTrace(sessionId: string, registration: FlueSessionRegistration): void;
221
+ /**
222
+ * Injects `x-correlation-id`/`x-spf-agent` by looking up the ACTIVE
223
+ * `Context`'s SpanContext trace id (`trace.getSpanContext(ctx)?.traceId`) in
224
+ * `traceIdRegistrations` — the same per-real-HTTP-call injection point
225
+ * `W3CTraceContextPropagator` uses for `traceparent`, and, critically, keyed
226
+ * on the one piece of `resolveFlueRootContext`'s return value that provably
227
+ * survives into that active context (see the module header's BLOCKER 1
228
+ * section for why a context VALUE does not). Injects NOTHING — no fallback,
229
+ * no last-known identity — when this exact trace id is not in the map: an
230
+ * unregistered/unmapped span (flue's own bookkeeping spans, or ANY span
231
+ * after every session has been unregistered) must not stamp a stale or
232
+ * unrelated session's `x-correlation-id`/`x-spf-agent` onto unrelated
233
+ * outbound traffic (a third-party call, a post-session straggler). A true
234
+ * no-op (nothing set) in that case — never throws, never invents a value.
235
+ * Replaces the old `XRequestIdPropagator`:
236
+ * `x-request-id` must NEVER reach the gateway (Envoy/Switchyard own it
237
+ * end-to-end; a client-sent value corrupts their own sampling) and this repo
238
+ * no longer has anything that wants it emitted.
239
+ */
240
+ export declare class GatewayHeadersPropagator implements TextMapPropagator {
111
241
  inject(ctx: Context, carrier: unknown, setter: TextMapSetter): void;
112
242
  extract(ctx: Context): Context;
113
243
  fields(): string[];
114
244
  }
115
- /**
116
- * Registers `traceparent` (SPF's deterministic agent-call span, as a W3C
117
- * carrier string) as the trace root for flue spans belonging to `sessionId`
118
- * — flue's instance id, minted by SPF and handed to `init(SfAgent, { id })`.
119
- * Overwrites a prior registration for the same id (a same-phase retry is
120
- * the same logical call; the current call wins).
121
- */
122
- export declare function registerFlueSessionTrace(sessionId: string, traceparent: string): void;
123
245
  /**
124
246
  * Idempotent — called from `run()`'s `finally`. Post-settlement bookkeeping
125
247
  * spans flue mints after this point simply resolve to an unparented root
126
248
  * (separate trace), which is preferable to leaking a registration whose id
127
- * a REUSED session id could collide with on a later phase.
249
+ * a REUSED session id could collide with on a later phase. Also prunes this
250
+ * session's entry out of `traceIdRegistrations` (parsed fresh from the
251
+ * departing registration's own `traceparent`, so it removes exactly the
252
+ * entry this session added.
128
253
  */
129
254
  export declare function unregisterFlueSessionTrace(sessionId: string): void;
130
255
  /**
@@ -137,6 +262,13 @@ export declare function unregisterFlueSessionTrace(sessionId: string): void;
137
262
  * unparented root span of its own) for an unmapped session id, a malformed
138
263
  * traceparent, an absent ctx — and, with no global propagator installed,
139
264
  * for everything. Exported for tests.
265
+ *
266
+ * Used by `@flue/opentelemetry` ONLY as `tracer.startSpan`'s parent
267
+ * argument (see the module header's BLOCKER 1 section) — the value handed
268
+ * back here is never itself activated, so it carries no context VALUES for
269
+ * `GatewayHeadersPropagator` to read; `x-correlation-id`/`x-spf-agent` are
270
+ * instead looked up by trace id, via `traceIdRegistrations`, which
271
+ * `registerFlueSessionTrace` populates independently of this function.
140
272
  */
141
273
  export declare function resolveFlueRootContext(_event: unknown, ctx: {
142
274
  id?: string;
@@ -157,3 +289,15 @@ export interface FluePropagationConfig {
157
289
  export declare function installFluePropagation(cfg: FluePropagationConfig | undefined | null, log?: (message: string) => void): void;
158
290
  /** Tests only: forget global installation state. Does NOT undo `setGlobalTracerProvider`/`registerInstrumentations` (the OTel API has no supported "un-register" — tests that need isolation run in a fresh process). */
159
291
  export declare function resetFluePropagationForTest(): void;
292
+ /**
293
+ * Whether `installFluePropagation` has actually installed the global
294
+ * TracerProvider/propagator/instrumentations in THIS process. Consulted by
295
+ * `ollama_provider.ts` (see its "Gateway headers" section) so `resolve()`/
296
+ * `modelFor()` know whether the instrumentation above already covers
297
+ * `traceparent`/`x-correlation-id`/`x-spf-agent` per real outbound call —
298
+ * supplying any of the three a second way when this is `true` would double
299
+ * it up on the wire (`UndiciInstrumentation` appends via `addHeader`, it
300
+ * does not replace). `false` — the common case, otel unconfigured — means
301
+ * `ollama_provider.ts` must supply all three itself.
302
+ */
303
+ export declare function isFluePropagationInstalled(): boolean;