@noodleseed/one 0.136.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 (124) 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 +4 -4
  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 +22 -13
  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/README.md +43 -8
  123. package/node_modules/@noodleseed/assistant/package.json +16 -2
  124. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
1
  import { normalizeServerVersion } from '@noodle-borg/module';
2
2
  import { accessUpdateRequestSchema } from '@noodle-borg/wire-contracts';
3
3
  import { readJsonBody, sendJson } from '../http-util.js';
4
+ import { deploymentOwnerSubject } from '../registry-helpers.js';
4
5
  import { authorizeControlPlane } from './control-plane.js';
5
6
  import { canManageMembers } from './org-admin.js';
6
7
  export async function handleAccessUpdate(req, res, registry, gate, controlPlane, audit, maxBody, tenant) {
@@ -30,7 +31,11 @@ export async function handleAccessUpdate(req, res, registry, gate, controlPlane,
30
31
  catch (error) {
31
32
  return sendJson(res, 400, { error: error.message });
32
33
  }
33
- const result = await registry.updateAccess(tenant, parsed.data.accessMode, serverVersion);
34
+ const result = await registry.updateAccess(tenant, {
35
+ accessMode: parsed.data.accessMode,
36
+ ...(parsed.data.ownerSubject !== undefined ? { ownerSubject: parsed.data.ownerSubject } : {}),
37
+ ...(serverVersion !== undefined ? { serverVersion } : {}),
38
+ });
34
39
  if (!result.ok) {
35
40
  return sendJson(res, result.status, {
36
41
  error: result.message,
@@ -38,6 +43,7 @@ export async function handleAccessUpdate(req, res, registry, gate, controlPlane,
38
43
  });
39
44
  }
40
45
  const accessMode = result.record.accessMode ?? 'owner-only';
46
+ const ownerSubject = deploymentOwnerSubject(result.record);
41
47
  await audit.emit({
42
48
  eventType: 'deployment.access.updated',
43
49
  org: tenant.org,
@@ -51,6 +57,12 @@ export async function handleAccessUpdate(req, res, registry, gate, controlPlane,
51
57
  details: {
52
58
  previousAccessMode: result.previousAccessMode,
53
59
  accessMode,
60
+ ...(result.previousOwnerSubject !== undefined
61
+ ? { previousOwnerSubject: result.previousOwnerSubject }
62
+ : {}),
63
+ ...(ownerSubject !== undefined ? { ownerSubject } : {}),
64
+ accessChanged: result.accessChanged,
65
+ ownerChanged: result.ownerChanged,
54
66
  changed: result.changed,
55
67
  },
56
68
  });
@@ -63,8 +75,14 @@ export async function handleAccessUpdate(req, res, registry, gate, controlPlane,
63
75
  ? { serverVersion: result.record.serverVersion }
64
76
  : {}),
65
77
  accessMode,
78
+ ...(ownerSubject !== undefined ? { ownerSubject } : {}),
66
79
  },
67
80
  previousAccessMode: result.previousAccessMode,
81
+ ...(result.previousOwnerSubject !== undefined
82
+ ? { previousOwnerSubject: result.previousOwnerSubject }
83
+ : {}),
84
+ accessChanged: result.accessChanged,
85
+ ownerChanged: result.ownerChanged,
68
86
  changed: result.changed,
69
87
  });
70
88
  }
@@ -1,8 +1,24 @@
1
1
  import { handleTenantEvents, handleTenantMetrics, handleTenantSession } from './analytics.js';
2
- import { parseTenantEventsPath, parseTenantMetricsPath, parseTenantSessionPath } from './paths.js';
2
+ import { handleTenantAssistantUsage } from './assistant-usage.js';
3
+ import { parseTenantAssistantUsagePath, parseTenantEventsPath, parseTenantMetricsPath, parseTenantSessionPath, } from './paths.js';
3
4
  /** Try the analytics read routes; returns true when the request was handled (or a guard ended it). */
4
5
  export function dispatchAnalyticsReads(req, res, url, deps) {
5
6
  const { gate, controlPlane, requestEventStore, applySecurityHeaders, enforceHttps, sendJson, tls, developerGrantStore, } = deps;
7
+ const assistantUsageRef = parseTenantAssistantUsagePath(url.pathname);
8
+ if (assistantUsageRef !== undefined && req.method === 'GET') {
9
+ applySecurityHeaders(res, tls);
10
+ if (enforceHttps(req, res, tls))
11
+ return true;
12
+ if (requestEventStore === undefined) {
13
+ sendJson(res, 409, { ok: false, error: 'assistant usage requires a configured event store' });
14
+ return true;
15
+ }
16
+ handleTenantAssistantUsage(req, res, gate, controlPlane, requestEventStore, assistantUsageRef, url, developerGrantStore).catch(() => {
17
+ if (!res.headersSent)
18
+ sendJson(res, 500, { error: 'internal error' });
19
+ });
20
+ return true;
21
+ }
6
22
  const metricsRef = parseTenantMetricsPath(url.pathname);
7
23
  if (metricsRef !== undefined && req.method === 'GET') {
8
24
  applySecurityHeaders(res, tls);
@@ -1,5 +1,5 @@
1
+ import { aggregateRequestEvents } from '@noodle-borg/observability';
1
2
  import { normalizedTimestamps, sendJson } from '../http-util.js';
2
- import { aggregateRequestEvents } from '../request-event-metrics.js';
3
3
  import { authorizeTenantControl, developerGrantRouteAccess } from './control-plane.js';
4
4
  /**
5
5
  * Tenant analytics read routes (ADR 0121 Stage B): `metrics` (aggregated), `events` (the stream), and
@@ -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;