@gr8ful/spf 0.18.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 (50) 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 +58 -3
  25. package/dist/core/issues/jira_provider.js +89 -18
  26. package/dist/core/issues/markdown_adf.d.ts +58 -0
  27. package/dist/core/issues/markdown_adf.js +705 -0
  28. package/dist/core/issues/provider.d.ts +23 -0
  29. package/dist/core/loop.d.ts +39 -1
  30. package/dist/core/loop.js +33 -2
  31. package/dist/core/ollama_provider.d.ts +96 -13
  32. package/dist/core/ollama_provider.js +172 -26
  33. package/dist/core/otel.js +10 -1
  34. package/dist/core/otel_propagation.d.ts +168 -24
  35. package/dist/core/otel_propagation.js +219 -43
  36. package/dist/core/permissions.d.ts +16 -1
  37. package/dist/core/permissions.js +91 -3
  38. package/dist/core/providers.js +8 -3
  39. package/dist/core/refine.js +13 -1
  40. package/dist/core/runner.d.ts +33 -2
  41. package/dist/core/runner.js +40 -5
  42. package/dist/core/tiering.js +7 -3
  43. package/dist/core/tracer.d.ts +7 -1
  44. package/dist/core/tracer.js +15 -3
  45. package/dist/ui/server/db.d.ts +8 -1
  46. package/dist/ui/server/db.js +21 -4
  47. package/dist/ui/server/serve.d.ts +7 -0
  48. package/dist/ui/server/serve.js +10 -7
  49. package/dist/ui/shared/types.d.ts +16 -0
  50. package/package.json +3 -3
@@ -311,6 +311,29 @@ export interface IssueAuthoringProvider {
311
311
  * roll-up, refine cannot function without authoring at all.
312
312
  */
313
313
  listChildren(parent: Issue): Promise<Issue[]>;
314
+ /**
315
+ * Best-effort: relate a freshly published tree's ROOT issue back to the
316
+ * spec issue (`specId`) it was refined FROM — `core/refine.ts`'s
317
+ * `publish()` calls this once per root node (a node with no `parent` of
318
+ * its own within the refined tree) when it was given a `specIssueId`.
319
+ *
320
+ * Deliberately NOT `linkChild`: that method sets the tracker's
321
+ * HIERARCHY field (Jira's `parent`, GitHub's sub-issues API), and a
322
+ * spec's own issue type (Story by default — `JiraIssueTypeMapSchema.spec`)
323
+ * frequently cannot legally PARENT a root node's type in Jira's
324
+ * issue-type hierarchy — see `JiraProvider`'s own doc comment on
325
+ * `publishSpecs()` for the same constraint. `linkToSpec` uses a plain,
326
+ * symmetric issue-to-issue reference instead (Jira's generic "issue
327
+ * link"), which has no such hierarchy restriction.
328
+ *
329
+ * OPTIONAL, not every tracker needs one: GitHub already gets a native,
330
+ * visible cross-reference for free the moment `renderBody`'s "## Parent"
331
+ * section renders a plain "#N" in the body — GitHub auto-links same-repo
332
+ * issue mentions into a real timeline "referenced this issue" event, no
333
+ * API call required. Jira does not do this for plain text, which is why
334
+ * `JiraProvider` implements this and `GitHubProvider` does not.
335
+ */
336
+ linkToSpec?(specId: string, issue: Issue): Promise<void>;
314
337
  }
315
338
  /**
316
339
  * Structural, not nominal: checks for the three methods rather than
@@ -106,7 +106,35 @@ export interface LedgerAttempt {
106
106
  error: string | null;
107
107
  /** HEAD's short sha after a successful (exit 0) iteration — unchanged from the previous attempt's if this iteration committed nothing new, which is exactly what `isStuck` below looks for. `null` only for an errored or non-accepted (nonzero exit) attempt, where nothing was checked out to read. */
108
108
  commit_sha: string | null;
109
+ /**
110
+ * Kept for backward compatibility with ledger rows written before
111
+ * `billable_tokens` (below) existed — on a row this old, `tokens` is the
112
+ * DISPLAY total (`sessions.total_tokens`, cache reads included), the only
113
+ * figure that era ever recorded. On every row written since, this field
114
+ * and `billable_tokens` are set to the SAME (billable) value; they only
115
+ * ever diverge across that one-time boundary, never within a single
116
+ * write. `cumulativeSpend` is what actually reads either — see its own
117
+ * doc comment for the precedence.
118
+ */
109
119
  tokens: number;
120
+ /**
121
+ * The BILLABLE figure (input + cache-write + output) — what
122
+ * `--max-tokens`/`overCumulativeBudget` is SUPPOSED to compare against,
123
+ * same metric `defaults.max_run_tokens` uses within one chain run (see
124
+ * `UsageBreakdown.billable_tokens`'s doc comment, `data_types.ts`). NOT
125
+ * the display total (`sessions.total_tokens`, cache reads included) —
126
+ * `cli/commands/loop.ts`'s readback reads `session.billable_tokens`,
127
+ * falling back to `total_tokens` only for a SESSION row that predates
128
+ * that sqlite column being populated.
129
+ *
130
+ * `undefined` on any LEDGER row written before this field existed — a
131
+ * genuinely old-format row, not a free ($0) iteration. `cumulativeSpend`
132
+ * falls back to `tokens` for exactly that row (the only figure it has,
133
+ * a display total it cannot retroactively convert), and
134
+ * `hasLegacyTokenRows` is what a caller uses to warn once that a ceiling
135
+ * check is now mixing metrics across the ledger's own history.
136
+ */
137
+ billable_tokens?: number;
110
138
  cost: number;
111
139
  failures: string[];
112
140
  started_at: string;
@@ -151,13 +179,22 @@ export declare function newLedger(input: {
151
179
  export declare function resolveGoalId(explicit: string | undefined): string;
152
180
  export interface CumulativeBudget {
153
181
  maxCost?: number;
182
+ /** `--max-tokens` — checked against BILLABLE tokens (see `LedgerAttempt.billable_tokens`'s own doc comment), the same metric `defaults.max_run_tokens` checks per-call inside one chain run. */
154
183
  maxTokens?: number;
155
184
  }
156
185
  export declare function cumulativeSpend(attempts: LedgerAttempt[]): {
157
186
  cost: number;
158
187
  tokens: number;
159
188
  };
160
- /** Whether the goal-scoped ceiling is already exhausted going into the NEXT iteration — a stronger, ledger-wide check than any single iteration's own budget. */
189
+ /**
190
+ * True when the ledger holds at least one attempt written before
191
+ * `billable_tokens` existed — `cumulativeSpend` is then silently mixing a
192
+ * display-total figure (that row's `tokens`) into a sum whose newer rows
193
+ * are billable. Used to print a one-time operator warning rather than let
194
+ * that mismatch pass unremarked; see `runLoop`'s call site.
195
+ */
196
+ export declare function hasLegacyTokenRows(attempts: LedgerAttempt[]): boolean;
197
+ /** Whether the goal-scoped ceiling is already exhausted going into the NEXT iteration — a stronger, ledger-wide check than any single iteration's own budget. `spend.tokens` must already be BILLABLE tokens (see `LedgerAttempt.tokens`) — this function just compares, it does not know which metric it was handed. */
161
198
  export declare function overCumulativeBudget(budget: CumulativeBudget, spend: {
162
199
  cost: number;
163
200
  tokens: number;
@@ -185,6 +222,7 @@ export interface IterationResult {
185
222
  error: string | null;
186
223
  /** `null` when nothing was committed this iteration (a no-op, or a throw before any commit). */
187
224
  commit_sha: string | null;
225
+ /** BILLABLE tokens — see `LedgerAttempt.tokens`'s own doc comment; `runIteration` (`cli/commands/loop.ts`) reads this straight from `LedgerAttempt`, so the two must stay the same metric. */
188
226
  tokens: number;
189
227
  cost: number;
190
228
  /** `null` when the iteration errored or exited non-zero — the stop check only ever runs against a chain that accepted its own work, same as `fixLoop` only re-verifies after a phase that didn't already throw. */
package/dist/core/loop.js CHANGED
@@ -132,10 +132,30 @@ export function newLedger(input) {
132
132
  export function resolveGoalId(explicit) {
133
133
  return explicit ?? newId(8);
134
134
  }
135
+ /**
136
+ * Per-attempt token figure for budget purposes: `billable_tokens` where the
137
+ * row has it, else `tokens` (an old-format row, predating that field — see
138
+ * `LedgerAttempt.billable_tokens`'s own doc comment). Never the other way
139
+ * around: a new row's `tokens` happens to equal its `billable_tokens` today,
140
+ * but `billable_tokens` is the one this function trusts on purpose.
141
+ */
142
+ function tokensForBudget(a) {
143
+ return a.billable_tokens ?? a.tokens;
144
+ }
135
145
  export function cumulativeSpend(attempts) {
136
- return attempts.reduce((acc, a) => ({ cost: acc.cost + a.cost, tokens: acc.tokens + a.tokens }), { cost: 0, tokens: 0 });
146
+ return attempts.reduce((acc, a) => ({ cost: acc.cost + a.cost, tokens: acc.tokens + tokensForBudget(a) }), { cost: 0, tokens: 0 });
137
147
  }
138
- /** Whether the goal-scoped ceiling is already exhausted going into the NEXT iteration — a stronger, ledger-wide check than any single iteration's own budget. */
148
+ /**
149
+ * True when the ledger holds at least one attempt written before
150
+ * `billable_tokens` existed — `cumulativeSpend` is then silently mixing a
151
+ * display-total figure (that row's `tokens`) into a sum whose newer rows
152
+ * are billable. Used to print a one-time operator warning rather than let
153
+ * that mismatch pass unremarked; see `runLoop`'s call site.
154
+ */
155
+ export function hasLegacyTokenRows(attempts) {
156
+ return attempts.some((a) => a.billable_tokens === undefined);
157
+ }
158
+ /** Whether the goal-scoped ceiling is already exhausted going into the NEXT iteration — a stronger, ledger-wide check than any single iteration's own budget. `spend.tokens` must already be BILLABLE tokens (see `LedgerAttempt.tokens`) — this function just compares, it does not know which metric it was handed. */
139
159
  export function overCumulativeBudget(budget, spend) {
140
160
  if (budget.maxCost !== undefined && spend.cost >= budget.maxCost)
141
161
  return true;
@@ -207,6 +227,13 @@ export async function runLoop(deps) {
207
227
  if (existing) {
208
228
  deps.log(`loop: resuming goal ${deps.goalId} — ${existing.attempts.length} attempt(s) already recorded`);
209
229
  }
230
+ // One-time info line, not a per-iteration one: legacy rows already IN the
231
+ // ledger (from a resumed goal) do not change count as this run proceeds,
232
+ // so there is nothing to re-warn about after the first check.
233
+ if (deps.budget.maxTokens !== undefined && hasLegacyTokenRows(ledger.attempts)) {
234
+ deps.log(`loop: goal ${deps.goalId} — ledger has attempt(s) recorded before billable-token tracking; ` +
235
+ "--max-tokens falls back to their display-total token count for those rows (see LedgerAttempt.billable_tokens)");
236
+ }
210
237
  let lastVerdict = ledger.attempts.length > 0 ? { passed: false, failures: ledger.attempts[ledger.attempts.length - 1].failures, artifacts: [] } : null;
211
238
  for (let i = ledger.attempts.length + 1; i <= deps.max; i++) {
212
239
  if (overCumulativeBudget(deps.budget, cumulativeSpend(ledger.attempts))) {
@@ -244,7 +271,11 @@ export async function runLoop(deps) {
244
271
  exit_code: result.exit_code,
245
272
  error: result.error,
246
273
  commit_sha: result.commit_sha,
274
+ // Every row written from here on sets both to the same (billable)
275
+ // value — see `LedgerAttempt.tokens`/`billable_tokens`'s doc comments
276
+ // for why they only ever diverge on an old-format row.
247
277
  tokens: result.tokens,
278
+ billable_tokens: result.tokens,
248
279
  cost: result.cost,
249
280
  failures,
250
281
  started_at: startedAt,
@@ -42,28 +42,111 @@
42
42
  * cost nothing at runtime either way.
43
43
  */
44
44
  import type { Provider } from "@earendil-works/pi-ai";
45
+ /**
46
+ * `OLLAMA_API_KEY`, trimmed, when the operator has set one — e.g. the
47
+ * Briefs gateway (Envoy AI Gateway) enforces a per-client bearer and 401s
48
+ * the dummy key `DUMMY_API_KEY` was designed for a bare local server that
49
+ * checks nothing. Falls back to the dummy exactly as before when unset, so
50
+ * THIS function's own return value — the resolved `Authorization` bearer —
51
+ * is byte-identical to pre-gateway behavior for a bare local Ollama server
52
+ * (the common case this module was built for). MINOR-H: that is narrower
53
+ * than "the whole request is unchanged" — it is not, even with
54
+ * `OLLAMA_API_KEY` unset: a `traceparent` (and, once `agent_flue.ts` has an
55
+ * adw_id/agent_name to give it, `x-correlation-id`/`x-spf-agent`) is ALWAYS
56
+ * sent now, gateway or no gateway (see the "Gateway headers" section below).
57
+ * A bare local Ollama server ignores headers it doesn't recognize, so this
58
+ * is harmless — just not byte-identical. Read fresh inside `resolve()` (see
59
+ * its call site below) — never cached — so a key exported mid-process (or
60
+ * changed) takes effect on the very next dispatch with no re-registration.
61
+ *
62
+ * MINOR 3: exported so `doctor.ts`'s `OLLAMA_BASE_URL reachability` probe
63
+ * calls this SAME function rather than reading `process.env.OLLAMA_API_KEY`
64
+ * raw — a prior version of that probe sent NO `Authorization` header at all
65
+ * when the env var was unset, which diverges from what a real dispatch
66
+ * sends (the dummy bearer below, always). Against a gateway that rejects a
67
+ * request with no `Authorization` header at all differently than one with a
68
+ * wrong/dummy bearer, that divergence could make doctor report reachable
69
+ * when a real dispatch would 401, or vice versa. Calling `ollamaApiKey()` in
70
+ * both places means doctor's probe and a real dispatch send byte-identical
71
+ * bearers for the same env state.
72
+ */
73
+ export declare function ollamaApiKey(): string;
74
+ /** SPF-side identity for one LLM call, as far as `registerOllamaModel`'s caller can supply it. Both fields optional — an absent one simply omits its header. */
75
+ export interface GatewayCallContext {
76
+ /** The run's adw_id — sent as `x-correlation-id` so the gateway groups this run's calls. */
77
+ adwId?: string;
78
+ /** The SPF agent name — sent as `x-spf-agent`. */
79
+ agentName?: string;
80
+ }
81
+ /** 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). */
82
+ export declare const X_REQUEST_ID_HEADER = "x-request-id";
83
+ /**
84
+ * A fresh W3C `traceparent` for one outbound call — used only on the NOT
85
+ * INSTALLED path (see the section above); when propagation IS installed,
86
+ * `resolve()` does not call this at all, relying entirely on the
87
+ * instrumentation's own per-request injection instead (this is what fixed
88
+ * BLOCKER A/MAJOR-C: this function used to be called unconditionally, and
89
+ * on the installed path it silently reused the one still-open span's
90
+ * traceparent for every call inside that span, which is both a duplicate
91
+ * header AND not actually fresh per call).
92
+ *
93
+ * Reuses the active OTel span context when one is installed and current —
94
+ * the same trace this call's other telemetry already belongs to — falling
95
+ * back to a brand-new random trace/span id pair when there is none (no span
96
+ * active at this exact point, e.g. a stray call before any span opened), so
97
+ * the gateway still gets a well-formed, per-call-unique traceparent either
98
+ * way. Never throws; `isSpanContextValid` is the same guard
99
+ * `otel_propagation.ts`'s own propagators use.
100
+ */
101
+ export declare function freshTraceparent(): string;
45
102
  /** Exported so `doctor.ts`'s reachability probe agrees with what a real dispatch resolves to — see its call site for why a `??`/`||` mismatch here matters. */
46
103
  export declare function ollamaBaseUrl(): string;
47
104
  /**
48
105
  * Registers `modelId` (the part after `ollama/` in an agent's `model`
49
106
  * config) with Flue's provider registry, alongside every other `ollama/*`
50
- * id ever registered this process. Idempotent: a repeat of an already-seen
51
- * id is a no-op no re-registration, no re-import. A concurrent call for
52
- * the SAME id joins the in-flight registration rather than returning early
53
- * (see `inflight`'s doc); `registeredIds` itself is only ever updated AFTER
54
- * `setProvider()` succeeds, so a failed attempt (a bad install, a bundler
55
- * that can't resolve the deep `.lazy` subpath, a future validation error)
56
- * leaves the id unregistered and eligible for a real retry — not
57
- * permanently and misleadingly marked "done" while nothing is actually
58
- * registered.
107
+ * id ever registered this process. NOT idempotent w.r.t. `ctx` (see MAJOR-D
108
+ * below) every call re-runs the union re-registration (dynamic imports
109
+ * are cheap after the first, and `setProvider()` is a cheap in-memory
110
+ * upsert), so this id's `Model.headers` always reflect the MOST RECENT
111
+ * `ctx` this function was called with, not just the first. A concurrent
112
+ * call for the SAME id joins the in-flight registration rather than running
113
+ * a second one in parallel (see `inflight`'s doc); `registeredIds` itself
114
+ * is only ever updated AFTER `setProvider()` succeeds, so a failed attempt
115
+ * (a bad install, a bundler that can't resolve the deep `.lazy` subpath, a
116
+ * future validation error) leaves the id unregistered and eligible for a
117
+ * real retry — not permanently and misleadingly marked "done" while
118
+ * nothing is actually registered.
59
119
  *
60
120
  * Must complete before the FIRST Flue dispatch that names this model
61
121
  * (agent_flue.ts's `run()` awaits this before `ensureRuntime()`/`start()`),
62
- * but is equally safe to call again later with a new id mid-process that
63
- * later call's union re-registration is exactly how a second model gets
64
- * added without orphaning the first (see the `registeredIds` doc above).
122
+ * but is equally safe to call again later with a new id, or the SAME id
123
+ * again, mid-process — a new id's union re-registration is how a second
124
+ * model gets added without orphaning the first (see the `registeredIds` doc
125
+ * above); a repeat of the SAME id is how MAJOR-D below is fixed.
126
+ *
127
+ * MAJOR-D (fixed): `ctx`, when given AND `isFluePropagationInstalled()` is
128
+ * false (see the "Gateway headers" section above — when it's true, these
129
+ * two headers come from the per-request `GatewayHeadersPropagator`
130
+ * instead), is stamped onto this id's `Model.headers` as
131
+ * `x-correlation-id`/`x-spf-agent`. A PRIOR version of this function
132
+ * returned immediately for an already-registered id (a false comment
133
+ * claimed "one spf process runs one adw_id for its whole lifetime" to
134
+ * justify this) — which meant every later agent/adw_id sharing a model id
135
+ * within one process (spf `loop`/`fanout`/`watch`, which run many adw_ids
136
+ * in ONE process, `fanout` concurrently) silently kept the FIRST
137
+ * registration's headers forever. Re-running the full registration on every
138
+ * call, unconditionally, fixes that for every case except one, which
139
+ * remains and is not silently swallowed: two flue agents dispatching
140
+ * CONCURRENTLY (not sequentially) to the SAME `ollama/<id>` model id race on
141
+ * `registrationContext`/`setProvider()` — whichever registration's
142
+ * `setProvider()` call lands last wins the headers BOTH calls' subsequent
143
+ * dispatches see, until the next registration for that id. This is a
144
+ * `fanout` concurrency > 1 scenario specifically (two DIFFERENT agents,
145
+ * same process, same model id, truly overlapping registrations) — a
146
+ * sequential loop/watch never hits it, since each call's `await` completes
147
+ * before the next one starts.
65
148
  */
66
- export declare function registerOllamaModel(modelId: string): Promise<void>;
149
+ export declare function registerOllamaModel(modelId: string, ctx?: GatewayCallContext): Promise<void>;
67
150
  /** Test-only: the most recently constructed provider object (see `lastProvider`'s doc). */
68
151
  export declare function providerForTest(): Provider<"openai-completions"> | undefined;
69
152
  /** Test-only: forgets accumulated ids so test files don't leak into each other. Does not touch Flue's own registry — pair with `resetModelsForTests()` from `@flue/runtime/internal`. */
@@ -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"],