@noodleseed/one 0.137.0 → 0.138.0

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 (123) hide show
  1. package/dist/commands/assistant-ops.d.ts.map +1 -1
  2. package/dist/commands/assistant-ops.js +30 -1
  3. package/dist/commands/assistant-ops.js.map +1 -1
  4. package/dist/commands/deploy-first-flow.d.ts +2 -0
  5. package/dist/commands/deploy-first-flow.d.ts.map +1 -1
  6. package/dist/commands/deploy-first-flow.js +46 -14
  7. package/dist/commands/deploy-first-flow.js.map +1 -1
  8. package/dist/commands/deploy-ops.d.ts.map +1 -1
  9. package/dist/commands/deploy-ops.js +27 -1
  10. package/dist/commands/deploy-ops.js.map +1 -1
  11. package/dist/commands/deploy-preflight.d.ts +1 -0
  12. package/dist/commands/deploy-preflight.d.ts.map +1 -1
  13. package/dist/commands/deploy-preflight.js +1 -0
  14. package/dist/commands/deploy-preflight.js.map +1 -1
  15. package/dist/commands/deploy-status-ops.d.ts +1 -0
  16. package/dist/commands/deploy-status-ops.d.ts.map +1 -1
  17. package/dist/commands/deploy-status-ops.js +59 -4
  18. package/dist/commands/deploy-status-ops.js.map +1 -1
  19. package/dist/commands/deployments-ops.d.ts.map +1 -1
  20. package/dist/commands/deployments-ops.js +2 -0
  21. package/dist/commands/deployments-ops.js.map +1 -1
  22. package/dist/commands/diagnostics-ops.d.ts +1 -0
  23. package/dist/commands/diagnostics-ops.d.ts.map +1 -1
  24. package/dist/commands/diagnostics-ops.js +3 -0
  25. package/dist/commands/diagnostics-ops.js.map +1 -1
  26. package/dist/deploy.d.ts +3 -0
  27. package/dist/deploy.d.ts.map +1 -1
  28. package/dist/deploy.js +4 -0
  29. package/dist/deploy.js.map +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/node_modules/@noodle-borg/admission-limits/dist/envelope.js +1 -1
  35. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +3 -3
  36. package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +1 -0
  37. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +6 -7
  38. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  39. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.d.ts +6 -2
  40. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.js +5 -2
  41. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.js +37 -0
  42. package/node_modules/@noodle-borg/assistant-gateway/dist/model-runtime.js +4 -0
  43. package/node_modules/@noodle-borg/{service/dist/assistant-model-stream.js → assistant-gateway/dist/model-stream.js} +34 -2
  44. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.d.ts +3 -0
  45. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.js +2 -1
  46. package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.d.ts +4 -1
  47. package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.js +2 -1
  48. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.d.ts +6 -1
  49. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.js +11 -5
  50. package/node_modules/@noodle-borg/assistant-gateway/package.json +5 -1
  51. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +10 -2
  52. package/node_modules/@noodle-borg/authoring/dist/assistant.js +4 -0
  53. package/node_modules/@noodle-borg/authoring/dist/index.d.ts +1 -1
  54. package/node_modules/@noodle-borg/authoring/dist/index.js +1 -1
  55. package/node_modules/@noodle-borg/authoring/dist/load-authored.js +1 -1
  56. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-assistant.js +17 -0
  57. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-hosted-deployment.js +14 -0
  58. package/node_modules/@noodle-borg/compiler/dist/artifact/types.d.ts +1 -7
  59. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +20 -8
  60. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +12 -8
  61. package/node_modules/@noodle-borg/control-plane/dist/portable.d.ts +2 -0
  62. package/node_modules/@noodle-borg/control-plane/dist/portable.js +1 -0
  63. package/node_modules/@noodle-borg/control-plane/dist/rollback-deployment.d.ts +61 -0
  64. package/node_modules/@noodle-borg/{service/dist/operations → control-plane/dist}/rollback-deployment.js +3 -2
  65. package/node_modules/@noodle-borg/control-plane/dist/rollback-result.d.ts +19 -0
  66. package/node_modules/@noodle-borg/control-plane/package.json +3 -1
  67. package/node_modules/@noodle-borg/developer-mcp/dist/contracts.js +4 -0
  68. package/node_modules/@noodle-borg/developer-mcp/dist/widgets/app-overview.js +1 -1
  69. package/node_modules/@noodle-borg/developer-mcp/dist/widgets/deployment-detail.js +1 -1
  70. package/node_modules/@noodle-borg/module/dist/contract.d.ts +1 -0
  71. package/node_modules/@noodle-borg/module/dist/deployment-automation.d.ts +1 -1
  72. package/node_modules/@noodle-borg/module/dist/endpoint-url.d.ts +11 -0
  73. package/node_modules/@noodle-borg/module/dist/endpoint-url.js +20 -0
  74. package/node_modules/@noodle-borg/module/dist/index.d.ts +1 -0
  75. package/node_modules/@noodle-borg/module/dist/index.js +1 -0
  76. package/node_modules/@noodle-borg/observability/dist/assistant-usage.js +217 -0
  77. package/node_modules/@noodle-borg/observability/dist/index.js +2 -0
  78. package/node_modules/@noodle-borg/{service → observability}/dist/request-event-metrics.js +5 -2
  79. package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +6 -0
  80. package/node_modules/@noodle-borg/observability/dist/request-events.js +2 -0
  81. package/node_modules/@noodle-borg/service/dist/assistant-bindings.js +3 -1
  82. package/node_modules/@noodle-borg/service/dist/developer-mcp/control-plane-adapter.js +3 -3
  83. package/node_modules/@noodle-borg/service/dist/developer-mcp/inspection.js +2 -1
  84. package/node_modules/@noodle-borg/service/dist/registry-access.js +73 -24
  85. package/node_modules/@noodle-borg/service/dist/registry-deploy-transaction.js +5 -2
  86. package/node_modules/@noodle-borg/service/dist/registry-helpers.d.ts +5 -9
  87. package/node_modules/@noodle-borg/service/dist/registry-helpers.js +4 -18
  88. package/node_modules/@noodle-borg/service/dist/registry-rollback.js +3 -1
  89. package/node_modules/@noodle-borg/service/dist/registry-status.js +4 -1
  90. package/node_modules/@noodle-borg/service/dist/registry-targets.js +4 -4
  91. package/node_modules/@noodle-borg/service/dist/registry-types.d.ts +11 -16
  92. package/node_modules/@noodle-borg/service/dist/registry.d.ts +8 -5
  93. package/node_modules/@noodle-borg/service/dist/registry.js +8 -5
  94. package/node_modules/@noodle-borg/service/dist/routes/access.js +19 -1
  95. package/node_modules/@noodle-borg/service/dist/routes/analytics-dispatch.js +17 -1
  96. package/node_modules/@noodle-borg/service/dist/routes/analytics.js +1 -1
  97. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +73 -59
  98. package/node_modules/@noodle-borg/service/dist/routes/assistant-doctor.js +1 -1
  99. package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +28 -0
  100. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +18 -0
  101. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-turn.js +6 -0
  102. package/node_modules/@noodle-borg/service/dist/routes/assistant-usage.js +42 -0
  103. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +20 -3
  104. package/node_modules/@noodle-borg/service/dist/routes/auth-doctor-live.js +1 -2
  105. package/node_modules/@noodle-borg/service/dist/routes/control-plane.js +49 -4
  106. package/node_modules/@noodle-borg/service/dist/routes/deploy-preflight.js +11 -1
  107. package/node_modules/@noodle-borg/service/dist/routes/endpoint-enrichment.js +2 -1
  108. package/node_modules/@noodle-borg/service/dist/routes/paths.js +4 -0
  109. package/node_modules/@noodle-borg/service/dist/routes/rollback.js +1 -1
  110. package/node_modules/@noodle-borg/service/dist/service.js +6 -0
  111. package/node_modules/@noodle-borg/service/dist/store/in-memory.d.ts +2 -5
  112. package/node_modules/@noodle-borg/service/dist/store/in-memory.js +9 -21
  113. package/node_modules/@noodle-borg/service/dist/store/json-file.d.ts +2 -5
  114. package/node_modules/@noodle-borg/service/dist/store/json-file.js +8 -20
  115. package/node_modules/@noodle-borg/service/dist/store/records.js +39 -0
  116. package/node_modules/@noodle-borg/service/dist/store.d.ts +21 -10
  117. package/node_modules/@noodle-borg/service/package.json +5 -1
  118. package/node_modules/@noodle-borg/wire-contracts/dist/control-plane.d.ts +120 -0
  119. package/node_modules/@noodle-borg/wire-contracts/dist/control-plane.js +123 -1
  120. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +4 -0
  121. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +15 -1
  122. package/node_modules/@noodleseed/assistant/package.json +1 -1
  123. package/package.json +2 -2
@@ -1,17 +1,16 @@
1
1
  import { ADMISSION_DEFAULTS } from '@noodle-borg/admission-limits/portable';
2
+ import { requestModelCompletion, } from '@noodle-borg/assistant-gateway/model-runtime';
2
3
  import { dispatchAssistantTool, projectAssistantGuide, publicSurfaceOf, selectAssistantModelTools, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
3
4
  import { validateJsonSchemaWithDefaults } from '@noodle-borg/compiler';
4
5
  import { guardedFetch } from '@noodle-borg/connector-http';
5
6
  import { evaluateToolAuthorization } from '@noodle-borg/protocol';
6
7
  import { executeTool } from '@noodle-borg/runtime';
7
8
  import { toolTouchesDelegatedAuth } from '../assistant-delegated-projection.js';
8
- import { readModelCompletion, } from '../assistant-model-stream.js';
9
9
  import { assistantViewAvailableData } from '../assistant-view-availability.js';
10
10
  import { invocationContextSystemMessages } from '../invocation-context.js';
11
- import { resolveManagedVariablesInString } from '../managed-config-expressions.js';
12
- import { resolveConfigScope } from '../store.js';
13
11
  import { interceptForElevation, offersSignIn } from './assistant-elevation.js';
14
12
  import { assistantKnowledgeModelTools, executeAssistantKnowledgeSearch, findAssistantKnowledgeComponent, KNOWLEDGE_CITATION_GUIDANCE, resolveAssistantKnowledge, } from './assistant-knowledge.js';
13
+ import { resolveAssistantModelBinding } from './assistant-model-binding.js';
15
14
  const MAX_MODEL_RESPONSE = 1 << 20;
16
15
  /**
17
16
  * The agent-loop bounds come from the admission envelope so an operator lowering them changes
@@ -22,21 +21,33 @@ function agentBounds(deps) {
22
21
  const envelope = deps.admissionEnvelope ?? ADMISSION_DEFAULTS;
23
22
  return { steps: envelope.modelStepsPerTurn, toolCalls: envelope.toolCallsPerTurn };
24
23
  }
24
+ export function createAssistantTurnStats() {
25
+ return {
26
+ modelRequests: 0,
27
+ toolCalls: 0,
28
+ interactionCount: 0,
29
+ promptTokens: 0,
30
+ completionTokens: 0,
31
+ reasoningTokens: 0,
32
+ totalTokens: 0,
33
+ };
34
+ }
25
35
  /** Run one model/tool loop against the immutable invocation-context snapshot for this turn. */
26
- export async function runAgentTurn(target, session, message, context, deps, emit, modelContext, pageContext) {
36
+ export async function runAgentTurn(target, session, message, context, deps, emit, modelContext, pageContext, stats = createAssistantTurnStats()) {
27
37
  const assistant = target.served.artifact.server.assistant;
28
38
  if (!assistant)
29
39
  return emit({ event: 'error', data: { code: 'assistant_unavailable' } });
30
- const scope = resolveConfigScope(session.tenant);
31
- const [variables, secrets] = await Promise.all([
32
- deps.registry.configStore.resolveConfigValues('variable', scope),
33
- deps.registry.configStore.resolveConfigValues('secret', scope),
34
- ]);
35
- const baseUrl = resolveManagedVariablesInString(assistant.model.baseUrl, variables).replace(/\/$/, '');
36
- const model = resolveManagedVariablesInString(assistant.model.model, variables);
37
- const apiKey = secrets[assistant.model.apiKey];
38
- if (!apiKey)
39
- return emit({ event: 'error', data: { code: 'model_not_configured' } });
40
+ const binding = await resolveAssistantModelBinding(target, session.tenant, session.deploymentId, deps);
41
+ if (!binding) {
42
+ return emit({
43
+ event: 'error',
44
+ data: {
45
+ code: assistant.model.kind === 'noodle-managed'
46
+ ? 'managed_model_unavailable'
47
+ : 'model_not_configured',
48
+ },
49
+ });
50
+ }
40
51
  const identityLine = signedInIdentityLine(session.caller, assistant.sessionClaims);
41
52
  const knowledge = await resolveAssistantKnowledge(target.served);
42
53
  const modelTools = selectTurnModelTools(target, session.caller);
@@ -125,8 +136,24 @@ export async function runAgentTurn(target, session, message, context, deps, emit
125
136
  // Across every step of this turn, not per step: eight tool calls is eight, however the model splits
126
137
  // them, or a model that loops one call per step would spend the budget a step at a time.
127
138
  let toolCallsThisTurn = 0;
139
+ let remainingTokens = binding.requestPolicy?.maxTokensPerTurn;
140
+ const turnSignal = binding.requestPolicy?.maxTurnMs === undefined
141
+ ? undefined
142
+ : AbortSignal.timeout(binding.requestPolicy.maxTurnMs);
128
143
  for (let step = 0; step < bounds.steps; step += 1) {
129
- const completion = await requestCompletion(baseUrl, model, apiKey, messages, target, session.caller, deps.modelFetch, (delta) => emit({ event: 'content', data: { delta } }), assistantKnowledgeModelTools(knowledge), modelTools);
144
+ if (remainingTokens !== undefined && remainingTokens <= 0) {
145
+ return emit({ event: 'error', data: { code: 'model_token_budget_exhausted' } });
146
+ }
147
+ const requestTokenLimit = Math.min(binding.requestPolicy?.maxCompletionTokens ?? Number.MAX_SAFE_INTEGER, remainingTokens ?? Number.MAX_SAFE_INTEGER);
148
+ const completion = await requestCompletion(binding, messages, target, session.caller, deps.modelFetch, (delta) => emit({ event: 'content', data: { delta } }), assistantKnowledgeModelTools(knowledge), modelTools, requestTokenLimit === Number.MAX_SAFE_INTEGER ? undefined : requestTokenLimit, turnSignal);
149
+ stats.modelRequests += 1;
150
+ stats.promptTokens += completion.usage?.promptTokens ?? 0;
151
+ stats.completionTokens += completion.usage?.completionTokens ?? 0;
152
+ stats.reasoningTokens += completion.usage?.reasoningTokens ?? 0;
153
+ stats.totalTokens += completion.usage?.totalTokens ?? 0;
154
+ if (remainingTokens !== undefined) {
155
+ remainingTokens = Math.max(0, remainingTokens - (completion.usage?.completionTokens ?? requestTokenLimit));
156
+ }
130
157
  const response = completion.choices[0]?.message;
131
158
  if (!response)
132
159
  return emit({ event: 'error', data: { code: 'invalid_model_response' } });
@@ -139,6 +166,7 @@ export async function runAgentTurn(target, session, message, context, deps, emit
139
166
  });
140
167
  for (const call of response.tool_calls) {
141
168
  toolCallsThisTurn += 1;
169
+ stats.toolCalls += 1;
142
170
  if (toolCallsThisTurn > bounds.toolCalls) {
143
171
  return emit({ event: 'error', data: { code: 'tool_call_budget_exhausted' } });
144
172
  }
@@ -184,6 +212,7 @@ export async function runAgentTurn(target, session, message, context, deps, emit
184
212
  now: assistantNow(deps),
185
213
  });
186
214
  if (elevation) {
215
+ stats.interactionCount += 1;
187
216
  emit(elevation.event);
188
217
  messages.push({
189
218
  role: 'tool',
@@ -223,6 +252,7 @@ export async function runAgentTurn(target, session, message, context, deps, emit
223
252
  onToolStarted: () => emit({ event: 'tool_started', data: { id: call.id, tool: tool.name } }),
224
253
  });
225
254
  if (dispatch.kind === 'event') {
255
+ stats.interactionCount += 1;
226
256
  return emit({ event: dispatch.event, data: dispatch.data });
227
257
  }
228
258
  const view = assistantViewAvailableData(target.served.artifact, {
@@ -252,15 +282,8 @@ export async function narrateInteractionResolution(target, session, tool, action
252
282
  const assistant = target.served.artifact.server.assistant;
253
283
  if (!assistant)
254
284
  return '';
255
- const scope = resolveConfigScope(session.tenant);
256
- const [variables, secrets] = await Promise.all([
257
- deps.registry.configStore.resolveConfigValues('variable', scope),
258
- deps.registry.configStore.resolveConfigValues('secret', scope),
259
- ]);
260
- const baseUrl = resolveManagedVariablesInString(assistant.model.baseUrl, variables).replace(/\/$/, '');
261
- const model = resolveManagedVariablesInString(assistant.model.model, variables);
262
- const apiKey = secrets[assistant.model.apiKey];
263
- if (!apiKey)
285
+ const binding = await resolveAssistantModelBinding(target, session.tenant, session.deploymentId, deps);
286
+ if (!binding)
264
287
  return '';
265
288
  const identityLine = signedInIdentityLine(session.caller, assistant.sessionClaims);
266
289
  const knowledge = await resolveAssistantKnowledge(target.served);
@@ -285,7 +308,7 @@ export async function narrateInteractionResolution(target, session, tool, action
285
308
  },
286
309
  ];
287
310
  let narrated = '';
288
- const completion = await requestCompletion(baseUrl, model, apiKey, messages, target, session.caller, deps.modelFetch, (delta) => {
311
+ const completion = await requestCompletion(binding, messages, target, session.caller, deps.modelFetch, (delta) => {
289
312
  narrated += delta;
290
313
  onDelta(delta);
291
314
  });
@@ -296,40 +319,31 @@ export async function narrateInteractionResolution(target, session, tool, action
296
319
  }
297
320
  return narrated;
298
321
  }
299
- async function requestCompletion(baseUrl, model, apiKey, messages, target, caller, injected, onContent = () => undefined, extraTools = [], selectedModelTools) {
300
- const url = new URL(`${baseUrl}/chat/completions`);
301
- if (url.protocol !== 'https:' || url.username || url.password)
302
- throw new Error('unsafe model endpoint');
303
- const init = {
304
- method: 'POST',
305
- headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
306
- body: JSON.stringify({
307
- model,
308
- stream: true,
309
- messages,
310
- tools: [
311
- // On a mixed surface an anonymous caller sees gated tools too: projecting one to the
312
- // surface is the author's opt-in to advertise it, and dispatch converts the call into a
313
- // sign-in offer instead of executing (ADR 0201 decision 4). The same selector feeds the
314
- // compact product guide, keeping instructions and callable tools in lockstep.
315
- ...(selectedModelTools ?? selectTurnModelTools(target, caller)).map((tool) => ({
316
- type: 'function',
317
- function: {
318
- name: tool.name,
319
- description: tool.description,
320
- parameters: tool.inputSchema,
321
- },
322
- })),
323
- ...extraTools,
324
- ],
325
- }),
326
- redirect: 'manual',
327
- signal: AbortSignal.timeout(30_000),
328
- };
329
- const response = injected ? await injected(url.href, init) : await guardedFetch(url, init);
330
- if (!response.ok)
331
- throw new Error(`model request failed (${response.status})`);
332
- return readModelCompletion(response, onContent, MAX_MODEL_RESPONSE);
322
+ async function requestCompletion(binding, messages, target, caller, injected, onContent = () => undefined, extraTools = [], selectedModelTools, maxCompletionTokens, signal) {
323
+ return requestModelCompletion({
324
+ binding,
325
+ messages,
326
+ tools: [
327
+ // On a mixed surface an anonymous caller sees gated tools too: projecting one to the
328
+ // surface is the author's opt-in to advertise it, and dispatch converts the call into a
329
+ // sign-in offer instead of executing (ADR 0201 decision 4). The same selector feeds the
330
+ // compact product guide, keeping instructions and callable tools in lockstep.
331
+ ...(selectedModelTools ?? selectTurnModelTools(target, caller)).map((tool) => ({
332
+ type: 'function',
333
+ function: {
334
+ name: tool.name,
335
+ description: tool.description,
336
+ parameters: tool.inputSchema,
337
+ },
338
+ })),
339
+ ...extraTools.map((tool) => ({ ...tool, type: 'function' })),
340
+ ],
341
+ fetcher: injected ?? ((url, init) => guardedFetch(new URL(url), init)),
342
+ onContent,
343
+ maxResponseBytes: MAX_MODEL_RESPONSE,
344
+ ...(maxCompletionTokens === undefined ? {} : { maxCompletionTokens }),
345
+ ...(signal === undefined ? {} : { signal }),
346
+ });
333
347
  }
334
348
  function selectTurnModelTools(target, caller) {
335
349
  const assistant = target.served.artifact.server.assistant;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { assistantCustomerIssuer, completeElevation, publicSurfaceOf, } from '@noodle-borg/assistant-gateway/portable';
3
+ import { tenantMcpUrl } from '@noodle-borg/module';
3
4
  import { readJsonBody, sendForbidden, sendJson } from '../http-util.js';
4
- import { tenantMcpUrl } from '../registry-helpers.js';
5
5
  import { now } from './assistant-route-http.js';
6
6
  import { authorizeControlPlane } from './control-plane.js';
7
7
  /**
@@ -0,0 +1,28 @@
1
+ import { resolveManagedVariablesInString } from '../managed-config-expressions.js';
2
+ import { resolveConfigScope } from '../store.js';
3
+ export async function resolveAssistantModelBinding(target, tenant, deploymentId, deps) {
4
+ const declaration = target.served.artifact.server.assistant?.model;
5
+ if (declaration === undefined)
6
+ return undefined;
7
+ if (declaration.kind === 'noodle-managed') {
8
+ return deps.managedModelResolver?.resolve({
9
+ tenant,
10
+ deploymentId,
11
+ });
12
+ }
13
+ const scope = resolveConfigScope(tenant);
14
+ const [variables, secrets] = await Promise.all([
15
+ deps.registry.configStore.resolveConfigValues('variable', scope),
16
+ deps.registry.configStore.resolveConfigValues('secret', scope),
17
+ ]);
18
+ const apiKey = secrets[declaration.apiKey];
19
+ if (!apiKey)
20
+ return undefined;
21
+ return {
22
+ source: 'operator',
23
+ baseUrl: resolveManagedVariablesInString(declaration.baseUrl, variables).replace(/\/$/, ''),
24
+ model: resolveManagedVariablesInString(declaration.model, variables),
25
+ apiKey,
26
+ };
27
+ }
28
+ //# sourceMappingURL=assistant-model-binding.js.map
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { ADMISSION_DEFAULTS, clientAddressBucket } from '@noodle-borg/admission-limits/portable';
3
3
  import { mintPublicSession, publicSurfaceOf, } from '@noodle-borg/assistant-gateway/portable';
4
+ import { assistantSessionUsageRequestEvent, captureAssistantUsage, } from '@noodle-borg/observability';
4
5
  import { assistantSessionResponseSchema } from '@noodle-borg/wire-contracts';
5
6
  import { readJsonBody, sendJson } from '../http-util.js';
6
7
  import { assistantBrowserConfiguration } from './assistant-configuration.js';
@@ -20,6 +21,7 @@ function tenantOf(embed) {
20
21
  return { org: embed.org, app: embed.app, env: embed.env };
21
22
  }
22
23
  export async function handlePublicAssistantSession(req, res, deps) {
24
+ const usageStartedAt = performance.now();
23
25
  const embeds = deps.publicEmbeds;
24
26
  const counters = deps.admissionCounters;
25
27
  if (!embeds || !counters) {
@@ -37,6 +39,8 @@ export async function handlePublicAssistantSession(req, res, deps) {
37
39
  // applies — losing fairness, never solvency.
38
40
  const addressBucket = clientAddressBucket(typeof req.headers['x-forwarded-for'] === 'string' ? req.headers['x-forwarded-for'] : undefined);
39
41
  let configuration;
42
+ let sponsoredBudget;
43
+ let usageSession;
40
44
  const result = await mintPublicSession({ embedId, origin, ...(addressBucket ? { addressBucket } : {}) }, deps.admissionEnvelope ?? ADMISSION_DEFAULTS, {
41
45
  embeds,
42
46
  counters,
@@ -44,10 +48,17 @@ export async function handlePublicAssistantSession(req, res, deps) {
44
48
  const target = await deps.registry.getActiveByTenant(tenantOf(embed));
45
49
  if (!target?.deploymentId)
46
50
  return undefined;
51
+ if (target.served.artifact.server.assistant?.model.kind === 'noodle-managed') {
52
+ sponsoredBudget = (await deps.managedModelResolver?.resolve({
53
+ tenant: tenantOf(embed),
54
+ deploymentId: target.deploymentId,
55
+ }))?.publicAdmission;
56
+ }
47
57
  // Read from the ACTIVE deployment rather than cached on the embed record — which is what lets
48
58
  // `noodle deploy` alone update a snippet a customer pasted months ago.
49
59
  return publicSurfaceOf(target.served.artifact.server.assistant);
50
60
  },
61
+ resolveBudgetBounds: async () => sponsoredBudget,
51
62
  createSession: async (input) => {
52
63
  const target = await deps.registry.getActiveByTenant(tenantOf(input.embed));
53
64
  if (!target?.deploymentId)
@@ -60,6 +71,9 @@ export async function handlePublicAssistantSession(req, res, deps) {
60
71
  clientId: input.embed.embedId,
61
72
  tenant: tenantOf(input.embed),
62
73
  deploymentId: target.deploymentId,
74
+ modelSource: target.served.artifact.server.assistant?.model.kind === 'noodle-managed'
75
+ ? 'noodle-managed'
76
+ : 'operator',
63
77
  origin: input.origin,
64
78
  publicEmbedId: input.embed.embedId,
65
79
  caller: { subject: input.subject, identityKind: 'anonymous' },
@@ -68,6 +82,7 @@ export async function handlePublicAssistantSession(req, res, deps) {
68
82
  expiresAt: new Date(current.getTime() + input.envelope.sessionIdleMs).toISOString(),
69
83
  absoluteExpiresAt: new Date(current.getTime() + input.envelope.sessionAbsoluteMs).toISOString(),
70
84
  });
85
+ usageSession = created.session;
71
86
  return { token: created.token, expiresAt: created.session.expiresAt };
72
87
  },
73
88
  // Opaque, server-minted, unlinkable to any person: nothing from the request becomes a subject.
@@ -95,6 +110,9 @@ export async function handlePublicAssistantSession(req, res, deps) {
95
110
  // Parse rather than trust: every published @noodleseed/assistant widget consumes this shape, and the
96
111
  // public mint must not be the path that quietly diverges from it (ADR 0151).
97
112
  assistantSessionResponseSchema.parse(sessionBody);
113
+ if (usageSession !== undefined) {
114
+ captureAssistantUsage(deps.captureRequestEvent, assistantSessionUsageRequestEvent(usageSession, performance.now() - usageStartedAt));
115
+ }
98
116
  return sendJson(res, 201, sessionBody);
99
117
  }
100
118
  //# sourceMappingURL=assistant-public-session.js.map
@@ -21,6 +21,12 @@ addressBucket) {
21
21
  const result = await admitPublicTurn({ sessionId: session.id, publicEmbedId, message, ...(addressBucket ? { addressBucket } : {}) }, deps.admissionEnvelope ?? ADMISSION_DEFAULTS, {
22
22
  counters,
23
23
  embeds,
24
+ resolveBudgetBounds: async () => session.modelSource !== 'noodle-managed'
25
+ ? undefined
26
+ : (await deps.managedModelResolver?.resolve({
27
+ tenant: session.tenant,
28
+ deploymentId: session.deploymentId,
29
+ }))?.publicAdmission,
24
30
  consumeTurn: (id, limit) => deps.store.consumeTurn(id, limit),
25
31
  now: () => now(deps),
26
32
  });
@@ -0,0 +1,42 @@
1
+ import { ASSISTANT_USAGE_METHOD, aggregateAssistantUsage } from '@noodle-borg/observability';
2
+ import { normalizedTimestamps, sendJson } from '../http-util.js';
3
+ import { authorizeTenantControl, developerGrantRouteAccess } from './control-plane.js';
4
+ const WINDOWS_MS = new Map([
5
+ ['24h', 24 * 60 * 60 * 1000],
6
+ ['7d', 7 * 24 * 60 * 60 * 1000],
7
+ ['30d', 30 * 24 * 60 * 60 * 1000],
8
+ ]);
9
+ /** Bounded raw-event scan; a filled scan is explicitly marked partial in the response. */
10
+ const ASSISTANT_USAGE_SCAN_LIMIT = 50_000;
11
+ export async function handleTenantAssistantUsage(req, res, gate, controlPlane, events, ref, url, developerGrants) {
12
+ const identity = await authorizeTenantControl(req, res, gate, controlPlane, ref.org, developerGrantRouteAccess(developerGrants, 'cloud:read'));
13
+ if (identity === false)
14
+ return;
15
+ const windowParam = url.searchParams.get('window') ?? '7d';
16
+ const windowMs = WINDOWS_MS.get(windowParam);
17
+ if (windowMs === undefined) {
18
+ return sendJson(res, 400, { error: '"window" must be one of 24h, 7d, 30d' });
19
+ }
20
+ const timestamps = normalizedTimestamps(res, {
21
+ until: url.searchParams.get('until') ?? undefined,
22
+ });
23
+ if (timestamps === undefined)
24
+ return;
25
+ const since = new Date(Date.now() - windowMs).toISOString();
26
+ const window = await events.list({
27
+ org: ref.org,
28
+ app: ref.app,
29
+ env: ref.env,
30
+ method: ASSISTANT_USAGE_METHOD,
31
+ since,
32
+ ...(timestamps.until === undefined ? {} : { until: timestamps.until }),
33
+ limit: ASSISTANT_USAGE_SCAN_LIMIT,
34
+ });
35
+ return sendJson(res, 200, {
36
+ ok: true,
37
+ window: { since, ...(timestamps.until === undefined ? {} : { until: timestamps.until }) },
38
+ ...(window.length >= ASSISTANT_USAGE_SCAN_LIMIT ? { truncated: true } : {}),
39
+ metrics: aggregateAssistantUsage(window),
40
+ });
41
+ }
42
+ //# sourceMappingURL=assistant-usage.js.map
@@ -2,13 +2,14 @@ import { clientAddressBucket, } from '@noodle-borg/admission-limits/portable';
2
2
  import { ASSISTANT_SESSION_IDLE_MS, dispatchAssistantTool, parseAssistantContextPreferences, parseAssistantCustomerRouting, resumeTurnMessage, shouldAutoResume, withAssistantSessionExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
3
3
  import { canonicalizeAuthorizationClaimValues } from '@noodle-borg/auth';
4
4
  import { validateJsonSchemaWithDefaults } from '@noodle-borg/compiler';
5
+ import { tenantMcpUrl } from '@noodle-borg/module';
6
+ import { assistantRefusedTurnUsageRequestEvent, assistantSessionUsageRequestEvent, assistantTurnNumber, assistantTurnUsageRequestEvent, captureAssistantUsage, } from '@noodle-borg/observability';
5
7
  import { evaluateToolAuthorization, mapExecutionError, mapResourceContents, mapResourcesList, mapToolOutput, mapToolsList, } from '@noodle-borg/protocol';
6
8
  import { executeResource } from '@noodle-borg/runtime';
7
9
  import { assistantMessageTurnRequestSchema, assistantResumeTurnRequestSchema, assistantSessionResponseSchema, } from '@noodle-borg/wire-contracts';
8
10
  import { readJsonBody, sendForbidden, sendJson, sendUnauthorized } from '../http-util.js';
9
11
  import { resolveInvocationContextSnapshot } from '../invocation-context.js';
10
- import { tenantMcpUrl } from '../registry-helpers.js';
11
- import { runAgentTurn } from './assistant-agent.js';
12
+ import { createAssistantTurnStats, runAgentTurn } from './assistant-agent.js';
12
13
  import { assistantBrowserConfiguration } from './assistant-configuration.js';
13
14
  import { elevateAssistantSession } from './assistant-elevation.js';
14
15
  import { executeAssistantKnowledgeSearch, findAssistantKnowledgeComponent, resolveAssistantKnowledge, } from './assistant-knowledge.js';
@@ -144,6 +145,7 @@ export async function handleAssistantClients(req, res, tenant, id, action, deps)
144
145
  sendJson(res, 405, { error: 'method not allowed' });
145
146
  }
146
147
  export async function handleAssistantSession(req, res, deps) {
148
+ const usageStartedAt = performance.now();
147
149
  const credentials = basicCredentials(req);
148
150
  if (!credentials)
149
151
  return sendUnauthorized(res, 'invalid assistant client');
@@ -245,6 +247,7 @@ export async function handleAssistantSession(req, res, deps) {
245
247
  clientId: client.id,
246
248
  tenant: client.tenant,
247
249
  deploymentId: target.deploymentId,
250
+ modelSource: assistant.model.kind === 'noodle-managed' ? 'noodle-managed' : 'operator',
248
251
  origin: parsed.origin,
249
252
  ...(customerRouting.customerRouting
250
253
  ? { customerRouting: customerRouting.customerRouting }
@@ -272,12 +275,15 @@ export async function handleAssistantSession(req, res, deps) {
272
275
  // Every published @noodleseed/assistant widget consumes this shape; parse (don't just test)
273
276
  // so a contract break fails loudly at mint time instead of stranding deployed widgets (ADR 0151).
274
277
  assistantSessionResponseSchema.parse(sessionBody);
278
+ captureAssistantUsage(deps.captureRequestEvent, assistantSessionUsageRequestEvent(session.session, performance.now() - usageStartedAt));
275
279
  return sendJson(res, 201, sessionBody);
276
280
  }
277
281
  export async function handleAssistantTurn(req, res, deps) {
282
+ const usageStartedAt = performance.now();
278
283
  const session = await authenticateSession(req, res, deps);
279
284
  if (!session)
280
285
  return;
286
+ const usageTurnNumber = assistantTurnNumber(session);
281
287
  applyBrowserCors(req, res, session.origin);
282
288
  const body = await readJsonBody(req, deps.maxBody);
283
289
  if (!body.ok)
@@ -296,6 +302,7 @@ export async function handleAssistantTurn(req, res, deps) {
296
302
  }
297
303
  const refusal = await refusePublicTurn(deps, session, 'resume', addressBucket);
298
304
  if (refusal) {
305
+ captureAssistantUsage(deps.captureRequestEvent, assistantRefusedTurnUsageRequestEvent(session, refusal.code, performance.now() - usageStartedAt));
299
306
  return sendJson(res, refusal.status, { error: refusal.message, code: refusal.code });
300
307
  }
301
308
  const pending = await deps.store.consumePendingResume(session.id);
@@ -344,6 +351,7 @@ export async function handleAssistantTurn(req, res, deps) {
344
351
  // of the model, so a refused turn costs the customer nothing.
345
352
  const refusal = await refusePublicTurn(deps, session, message, addressBucket);
346
353
  if (refusal) {
354
+ captureAssistantUsage(deps.captureRequestEvent, assistantRefusedTurnUsageRequestEvent(session, refusal.code, performance.now() - usageStartedAt));
347
355
  return sendJson(res, refusal.status, { error: refusal.message, code: refusal.code });
348
356
  }
349
357
  // The visitor typed first: any pending post-sign-in resume is moot, and must not fire later.
@@ -376,12 +384,13 @@ export async function handleAssistantTurn(req, res, deps) {
376
384
  });
377
385
  res.flushHeaders?.();
378
386
  const events = [];
387
+ const stats = createAssistantTurnStats();
379
388
  const emit = (event) => {
380
389
  events.push(event);
381
390
  res.write(`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`);
382
391
  };
383
392
  try {
384
- await runAgentTurn(target, session, message.trim(), invocationContext, deps, emit, turn?.modelContext, turn?.pageContext);
393
+ await runAgentTurn(target, session, message.trim(), invocationContext, deps, emit, turn?.modelContext, turn?.pageContext, stats);
385
394
  const assistantContent = events
386
395
  .filter((event) => event.event === 'content')
387
396
  .map((event) => String(event.data.delta ?? ''))
@@ -395,6 +404,14 @@ export async function handleAssistantTurn(req, res, deps) {
395
404
  emit({ event: 'error', data: { code: 'model_transport_failed', retryable: true } });
396
405
  }
397
406
  res.end('event: done\ndata: {}\n\n');
407
+ const error = events.find((event) => event.event === 'error');
408
+ captureAssistantUsage(deps.captureRequestEvent, assistantTurnUsageRequestEvent(session, {
409
+ outcome: error === undefined ? 'delivered' : 'failed',
410
+ counters: stats,
411
+ turnNumber: usageTurnNumber,
412
+ durationMs: performance.now() - usageStartedAt,
413
+ ...(typeof error?.data.code === 'string' ? { errorKind: error.data.code } : {}),
414
+ }));
398
415
  }
399
416
  /** Standard MCP operations exposed to an initialized app inside the embedded assistant host. */
400
417
  export async function handleAssistantAppRequest(req, res, deps) {
@@ -1,8 +1,7 @@
1
1
  import { bearerToken } from '@noodle-borg/control-plane/portable';
2
- import { normalizeServerVersion } from '@noodle-borg/module';
2
+ import { normalizeServerVersion, tenantMcpUrl } from '@noodle-borg/module';
3
3
  import { freezeCustomerRoutes, resolveCustomerRouteBinding, } from '@noodle-borg/runtime';
4
4
  import { sendJson } from '../http-util.js';
5
- import { tenantMcpUrl } from '../registry-helpers.js';
6
5
  /** Verify one real customer identity and probe delegated bindings without calling business APIs. */
7
6
  export async function handleLiveAuthDoctor(req, res, registry, tenant, options) {
8
7
  let serverVersion;
@@ -8,11 +8,12 @@ import { baseFromRequest, readDeployBody, sendForbidden, sendJson, sendUnauthori
8
8
  import { endpointUrlOptionsForOrg } from '../mcp-public-routing.js';
9
9
  import { ensurePersonalWorkspace } from '../personal-workspace.js';
10
10
  import { manifestUsesUserRoot } from '../registry-access.js';
11
- import { tenantMcpUrl } from '../registry-helpers.js';
12
11
  import { isIdentityAccessMode } from './access-mode.js';
13
12
  import { validateDeployIdempotency } from './deploy-idempotency.js';
14
13
  import { provisionPublicEmbed } from './deploy-public-embed.js';
15
14
  import { respondDeploymentActivationError } from './deployment-activation-response.js';
15
+ import { tenantMcpUrl } from './endpoint-enrichment.js';
16
+ import { canManageMembers } from './org-admin.js';
16
17
  export { handleConfigValues } from './config-values.js';
17
18
  export async function handleDeploy(req, res, registry, options, maxBody, tenant, gate, controlPlane, audit) {
18
19
  // GHD-3: a GitHub run deploys with a run-scoped token on x-noodle-run-token — NOT a control-plane
@@ -66,6 +67,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
66
67
  let declaredAccessMode;
67
68
  let orgMembershipSources;
68
69
  let deploymentSource;
70
+ let requestedOwnerSubject, ownerSubject;
69
71
  let serverVersion;
70
72
  try {
71
73
  const raw = JSON.parse(body.text);
@@ -88,6 +90,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
88
90
  orgMembershipSources = parsed.data.orgMembershipSources;
89
91
  hostedAssets = parsed.data.hostedAssets;
90
92
  deploymentSource = parsed.data.deploymentSource;
93
+ requestedOwnerSubject = parsed.data.ownerSubject;
91
94
  serverVersion =
92
95
  parsed.data.serverVersion === undefined
93
96
  ? '1'
@@ -96,6 +99,18 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
96
99
  catch (error) {
97
100
  return sendJson(res, 400, { error: `invalid deploy request: ${error.message}` });
98
101
  }
102
+ if (isRunDeploy && requestedOwnerSubject !== undefined) {
103
+ return sendJson(res, 400, {
104
+ error: 'automated deploys cannot select an explicit deployment owner',
105
+ code: 'owner_subject_not_allowed_for_automation',
106
+ });
107
+ }
108
+ if (!isRunDeploy) {
109
+ const selection = await authorizeDeploymentOwnerSelection(res, controlPlane, tenant.org, accessMode, identity, requestedOwnerSubject);
110
+ if (!selection.ok)
111
+ return;
112
+ ownerSubject = selection.ownerSubject;
113
+ }
99
114
  // GHD-3 trusted-phase auth (ADR 0132 Decision 3/4): a run deploy is authorized entirely by its
100
115
  // run token plus the access mode resolved here — the control-plane identity was skipped above.
101
116
  // Every rejection carries a machine-readable `code` so `noodle github runs <id>` can name the
@@ -119,9 +134,6 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
119
134
  ...(provenance?.ownerSubject === undefined
120
135
  ? {}
121
136
  : { previousOwnerSubject: provenance.ownerSubject }),
122
- ...(provenance?.ownerEmail === undefined
123
- ? {}
124
- : { previousOwnerEmail: provenance.ownerEmail }),
125
137
  });
126
138
  if (automation.kind === 'denied') {
127
139
  return sendJson(res, automation.status, {
@@ -145,6 +157,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
145
157
  accessMode = automation.accessMode;
146
158
  orgMembershipSources ??= provenance?.orgMembershipSources;
147
159
  identity = automation.actor;
160
+ ownerSubject = automation.ownerSubject;
148
161
  automationId = automation.automationId;
149
162
  }
150
163
  else {
@@ -154,6 +167,12 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
154
167
  });
155
168
  }
156
169
  }
170
+ if (isRunDeploy && accessMode === 'owner-only' && ownerSubject === undefined) {
171
+ return sendJson(res, 400, {
172
+ error: 'owner-only automation requires an existing human owner to preserve',
173
+ code: 'owner_only_unsupported_for_automation',
174
+ });
175
+ }
157
176
  // Identity access modes bind the data plane to verified identity, so the deployer must be authenticated.
158
177
  // (Localhost dev runs the gate open; such deploys are rejected for lack of an owner/audit actor.)
159
178
  if (isIdentityAccessMode(accessMode) && !identity) {
@@ -238,6 +257,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
238
257
  connectors,
239
258
  actor: identity || undefined,
240
259
  accessMode,
260
+ ownerSubject,
241
261
  ...(orgMembershipSources !== undefined ? { orgMembershipSources } : {}),
242
262
  hostedAssets,
243
263
  serverVersion,
@@ -339,6 +359,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
339
359
  ...(identity?.email !== undefined ? { actorEmail: identity.email } : {}),
340
360
  details: {
341
361
  accessMode: result.accessMode ?? accessMode,
362
+ ...(ownerSubject !== undefined ? { ownerSubject } : {}),
342
363
  serverVersion: result.serverVersion,
343
364
  hasConnectors: connectors !== undefined,
344
365
  },
@@ -388,6 +409,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
388
409
  deploymentId: result.deploymentId,
389
410
  serverVersion: result.serverVersion,
390
411
  accessMode: result.accessMode,
412
+ ...(result.ownerSubject !== undefined ? { ownerSubject: result.ownerSubject } : {}),
391
413
  url: tenantMcpUrl(base, tenant, result.serverVersion, endpointOptions),
392
414
  defaultUrl: tenantMcpUrl(base, tenant, undefined, endpointOptions),
393
415
  ...(embedId !== undefined ? { embedId } : {}),
@@ -398,6 +420,29 @@ function normalizeParsedServerVersion(value) {
398
420
  throw new Error('"serverVersion" must be a version string');
399
421
  return normalizeServerVersion(value);
400
422
  }
423
+ export async function authorizeDeploymentOwnerSelection(res, controlPlane, org, accessMode, identity, requestedOwnerSubject) {
424
+ if (accessMode !== 'owner-only')
425
+ return { ok: true };
426
+ if (identity === undefined) {
427
+ sendJson(res, 401, {
428
+ error: 'owner-only deployments require an authenticated deployer',
429
+ code: 'deployer_identity_required',
430
+ });
431
+ return { ok: false };
432
+ }
433
+ if (requestedOwnerSubject === undefined || requestedOwnerSubject === identity.subject) {
434
+ return { ok: true, ownerSubject: identity.subject };
435
+ }
436
+ if (identity.developerGrantId !== undefined ||
437
+ !(await canManageMembers(controlPlane, org, identity))) {
438
+ sendJson(res, 403, {
439
+ error: 'Selecting a different deployment owner requires organization owner access.',
440
+ code: 'deployment_owner_authorization_required',
441
+ });
442
+ return { ok: false };
443
+ }
444
+ return { ok: true, ownerSubject: requestedOwnerSubject };
445
+ }
401
446
  export async function handleWhoami(req, res, gate, controlPlane, options = {}) {
402
447
  // Identity introspection names no org or environment, so no grant context can describe it.
403
448
  const identity = await authorizeControlPlane(req, res, gate, {