@cspeach/cli 1.1.33 → 1.1.35

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 (119) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +182 -39
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +19 -6
  33. package/dist/commands/help.js +2 -2
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +6 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/router-spend.js +3 -0
  62. package/dist/cost/session-cost.js +4 -0
  63. package/dist/doctor/checks/azure.js +100 -0
  64. package/dist/doctor/checks/credits.js +4 -3
  65. package/dist/doctor/checks/usage-reporting.js +22 -0
  66. package/dist/doctor/run.js +7 -0
  67. package/dist/lib/graceful-exit.js +17 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/projects/answer-blockers.js +25 -12
  76. package/dist/renderer/change-card.js +28 -4
  77. package/dist/renderer/footer-line.js +7 -1
  78. package/dist/renderer/notices.js +27 -13
  79. package/dist/renderer/progress-chatter.js +3 -1
  80. package/dist/renderer/status-footer.js +13 -6
  81. package/dist/renderer/tool-labels.js +8 -2
  82. package/dist/renderer/verify-chain.js +41 -18
  83. package/dist/renderer/wrap-rows.js +87 -0
  84. package/dist/repl/ask-or-no.js +18 -0
  85. package/dist/repl/builtin-commands.js +10 -2
  86. package/dist/repl/post-turn-status.js +3 -2
  87. package/dist/repl/safety-confirm.js +47 -2
  88. package/dist/repl/session-spend-line.js +4 -1
  89. package/dist/repl/startup-account.js +51 -0
  90. package/dist/repl.js +217 -48
  91. package/dist/router/byok-classifier.js +41 -14
  92. package/dist/router/classifier.js +5 -16
  93. package/dist/router/router-target.js +41 -0
  94. package/dist/sap/connection-manager.js +15 -0
  95. package/dist/sap/onboarding.js +70 -64
  96. package/dist/sap/session-end.js +66 -0
  97. package/dist/session/audit-export.js +11 -4
  98. package/dist/skills/manifest-client.js +37 -4
  99. package/dist/skills/skills-cache.js +115 -0
  100. package/dist/skills/source-authed.js +276 -0
  101. package/dist/tools/ask-question.js +1 -1
  102. package/dist/tools/filesystem/glob.js +12 -3
  103. package/dist/tools/filesystem/grep.js +14 -3
  104. package/dist/tools/sap-read.js +22 -22
  105. package/dist/tools/sap-write.js +249 -18
  106. package/dist/tools/snapshot.js +26 -0
  107. package/dist/tools/tool-loading.js +9 -10
  108. package/dist/ui/app.js +19 -9
  109. package/dist/ui/approval-modal.js +37 -5
  110. package/dist/ui/live-area.js +14 -1
  111. package/dist/ui/widgets/ask-card.js +183 -107
  112. package/dist/ui/widgets/coaching-picker.js +22 -1
  113. package/dist/ui/widgets/confirm-card.js +102 -76
  114. package/dist/usage/usage-queue.js +323 -0
  115. package/dist/usage/usage-reporter.js +270 -0
  116. package/package.json +4 -3
  117. package/dist/skills/bundled-skills.js +0 -286
  118. package/dist/skills/source-bundled.js +0 -20
  119. package/dist/skills/source-manifest.js +0 -36
@@ -10,6 +10,7 @@ import Anthropic from '@anthropic-ai/sdk';
10
10
  import { getDeviceFingerprint } from '../auth/device.js';
11
11
  import { createManagedSkillSource } from '../skills/source-managed.js';
12
12
  import { CLI_VERSION } from '../lib/version.js';
13
+ import { MANAGED_PROFILE } from './connection-profile.js';
13
14
  export function createManagedProvider(cfg, getBearer) {
14
15
  const skillSource = createManagedSkillSource(cfg, getBearer);
15
16
  // Headers every managed request carries (the real call and the keepalive
@@ -28,6 +29,7 @@ export function createManagedProvider(cfg, getBearer) {
28
29
  return {
29
30
  name: 'anthropic-managed',
30
31
  mode: 'managed',
32
+ profile: MANAGED_PROFILE,
31
33
  skillSource,
32
34
  async createStream(params, options = {}) {
33
35
  const sdk = new Anthropic({
@@ -0,0 +1,7 @@
1
+ export const AZURE_PROFILE_V0 = Object.freeze({
2
+ id: 'azure', shaping: 'direct', cacheMarkers: true, betas: Object.freeze([]), serverFallbacks: false,
3
+ blockBinding: 'off', toolChanges: false, deferredTools: 'static', effort: 'main-only',
4
+ structuredOutputs: true, keepalive: false, stripInternalHeaders: 'all', servedConfig: 'none', router: 'direct',
5
+ adoptServedModel: false, modelIdentity: 'deployment',
6
+ });
7
+ export const AZURE_PROFILE = AZURE_PROFILE_V0;
@@ -0,0 +1,49 @@
1
+ import { AZURE_PROFILE } from './azure-profile.js';
2
+ export const MANAGED_PROFILE = Object.freeze({
3
+ id: 'managed', shaping: 'proxy', cacheMarkers: false, betas: Object.freeze(['compact-2026-01-12']), serverFallbacks: false,
4
+ blockBinding: 'allow-list-when-proxy-serves', toolChanges: true, deferredTools: 'served', effort: 'all',
5
+ structuredOutputs: true, keepalive: true, stripInternalHeaders: 'none', servedConfig: 'full', router: 'proxy',
6
+ adoptServedModel: true, modelIdentity: 'model-id',
7
+ });
8
+ export const BYOK_PROFILE = Object.freeze({
9
+ id: 'byok', shaping: 'direct', cacheMarkers: true, betas: Object.freeze(['compact-2026-01-12']), serverFallbacks: true,
10
+ blockBinding: 'allow-list', toolChanges: true, deferredTools: 'served', effort: 'all',
11
+ structuredOutputs: true, keepalive: true, stripInternalHeaders: 'all', servedConfig: 'router-only', router: 'direct',
12
+ adoptServedModel: false, modelIdentity: 'model-id',
13
+ });
14
+ export const AI_HUB_PROFILE = Object.freeze({
15
+ id: 'ai-hub', shaping: 'legacy', cacheMarkers: false, betas: Object.freeze([]), serverFallbacks: false,
16
+ blockBinding: 'allow-list', toolChanges: false, deferredTools: 'static', effort: 'all',
17
+ structuredOutputs: false, keepalive: false, stripInternalHeaders: 'role-only', servedConfig: 'none', router: 'direct',
18
+ adoptServedModel: false, modelIdentity: 'model-id',
19
+ });
20
+ export const LOCAL_PROFILE = Object.freeze({
21
+ id: 'local', shaping: 'openai', cacheMarkers: false, betas: Object.freeze([]), serverFallbacks: false,
22
+ blockBinding: 'allow-list', toolChanges: false, deferredTools: 'static', effort: 'all',
23
+ structuredOutputs: false, keepalive: false, stripInternalHeaders: 'all', servedConfig: 'none', router: 'local-rules',
24
+ adoptServedModel: false, modelIdentity: 'model-id',
25
+ });
26
+ /** Test fakes and undefined modes: today's fall-through behaviour at every call site. */
27
+ export const UNKNOWN_PROFILE = Object.freeze({
28
+ id: 'unknown', shaping: 'direct', cacheMarkers: false, betas: Object.freeze([]), serverFallbacks: false,
29
+ blockBinding: 'allow-list', toolChanges: false, deferredTools: 'env-only', effort: 'all',
30
+ structuredOutputs: false, keepalive: false, stripInternalHeaders: 'all', servedConfig: 'none', router: 'proxy',
31
+ adoptServedModel: false, modelIdentity: 'model-id',
32
+ });
33
+ const BY_MODE = new Map([
34
+ ['managed', MANAGED_PROFILE], ['byok', BYOK_PROFILE], ['ai-hub', AI_HUB_PROFILE], ['local', LOCAL_PROFILE],
35
+ ['azure', AZURE_PROFILE],
36
+ ]);
37
+ export function profileForMode(mode) {
38
+ return (mode !== undefined && BY_MODE.get(mode)) || UNKNOWN_PROFILE;
39
+ }
40
+ /** The provider's own profile, else the one its mode names (plan D2). */
41
+ export function profileOf(p) {
42
+ return p.profile ?? profileForMode(p.mode);
43
+ }
44
+ /** Whether the loop may send output_config.effort on a call with this role. */
45
+ export function sendsEffortFor(profile, role) {
46
+ if (profile.effort === 'off')
47
+ return false;
48
+ return profile.effort === 'all' || (role ?? 'session_default') === 'session_default';
49
+ }
@@ -8,6 +8,7 @@ import { recordCompletedToolCall } from '../session/pending.js';
8
8
  import { loadConfig } from '../config/loader.js';
9
9
  import { resolveModelRole, resolveEffort, modelAcceptsEffort, resolveAutoCompactThreshold, callModelFor } from '../models/resolve.js';
10
10
  import { getCustomerModelConfig, managedProxyServesNewShapes, servedToolLoading } from '../models/server-config.js';
11
+ import { familyOf, familyAcceptsEffort } from '../models/model-identity.js';
11
12
  import { buildToolList, buildSkillToolAddition, placeSystemMessagesInPlace, resolveToolLoading, } from '../tools/tool-loading.js';
12
13
  import { settleSessionToolLoading } from './tool-loading-pin.js';
13
14
  import { resolveSkillAlias } from '../skill-catalog.js';
@@ -29,8 +30,10 @@ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
29
30
  // (progress-chatter import removed 2026-05-01 — superseded by CC-style
30
31
  // two-line dispatch renderer; re-add if a future in-place spinner returns)
31
32
  import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
33
+ import { reportCallUsage } from '../usage/usage-reporter.js';
32
34
  import { appendCostLine, buildEntry as buildCostEntry, perCallTokens, summariseInputTransformations } from '../cost/cost-log.js';
33
- import { modelAcceptsBinding, sessionMaxTokens, clampMaxTokens } from './provider-shape.js';
35
+ import { sessionMaxTokens, clampMaxTokens, sendsBlockBinding } from './provider-shape.js';
36
+ import { profileOf, sendsEffortFor } from './connection-profile.js';
34
37
  import { computeSessionCost } from '../cost/session-cost.js';
35
38
  import { refreshCreditsAfterTurn, creditsForCostLog, beginCreditsRequest } from '../cost/credits-wire.js';
36
39
  import { globalStore } from '../ui/sap-state-store.js';
@@ -224,50 +227,140 @@ export async function inkAwareConfirm(q, opts) {
224
227
  return 'n';
225
228
  }
226
229
  /**
227
- * Choices picker for an answer-blocker that carries `options`. Renders the
228
- * native Ink AskCard (the same CC-style picker the ask_question tool
229
- * uses) with the candidate answers plus "Write my own" and "Skip" escapes.
230
+ * The picker for one spec-gap blocker (Task 3, 2026-10-12). It asks through
231
+ * the normal question path: a one-question v2 form, the same shape the
232
+ * ask_question tool sends, drawn by AskCard.
230
233
  *
231
- * - headless → skip (never blocks on stdin)
232
- * - classic (non-Ink) → 'write' so the caller falls back to the free-text box
233
- * - Ink → choice/multi modal; Esc or empty selection → skip
234
+ * - "Blocker 2 of 4" on the card frame's top edge (Task 4); the question is the bare text
235
+ * - Ctrl+C on the card → { kind: 'stop' }: the remaining blockers are not asked
236
+ * - options show in sentence case (the stored answer keeps the words as
237
+ * written), then a plain "Skip for now" option; the card's own inline
238
+ * "Type something else…" row takes a typed answer, so no second text card
239
+ * - a blocker with no options is a free-text form question (Esc skips)
240
+ * - asked under the card-slot focus claim; one scrollback line per answer
241
+ * ("Blocker 2 of 4 → <answer>"), so answered blockers stay on screen
242
+ * - headless → skip (never blocks); classic (non-Ink) → 'write' (free text)
234
243
  *
235
- * The two sentinels are filtered out of any real picked answer; for multi-select
236
- * the modal returns comma-joined values, so Write/Skip win over any co-selected
237
- * options.
244
+ * Option values are index ids (o0, o1…) so an option with a comma survives a
245
+ * multi-select answer (comma-joined values).
238
246
  */
239
- const CHOOSE_WRITE = '__cspeach_write__';
240
247
  const CHOOSE_SKIP = '__cspeach_skip__';
248
+ const SKIP_LABEL = 'Skip for now';
249
+ const SKIP_DESCRIPTION = 'Stays open. Answer it later at cspeach.dev/edit.';
250
+ const SKIPPED_TRACE = 'skipped for now';
251
+ const STOPPED_TRACE = 'stopped';
252
+ /**
253
+ * A first word that is a name, not prose (Task 4, m1): it holds _ / digits /
254
+ * a slash (zcl_foo, s4hk903359, zfin/pkg), is all capitals (ZTEST_LAS, ERDAT)
255
+ * or has a capital after its first letter (eTag). Such a word is never recased.
256
+ */
257
+ const Z_Y_ENGLISH = new Set(['yes', 'you', 'your', 'yours', 'yet', 'year', 'years', 'yearly', 'yield', 'zero', 'zone', 'zones', 'zoom']);
258
+ function looksLikeIdentifier(word) {
259
+ if (/[_\d/]/.test(word))
260
+ return true;
261
+ // A Z/Y customer-namespace name (zfin, ytools) unless it is a common English word.
262
+ if (/^[zy][a-z0-9_]{2,}$/i.test(word) && !Z_Y_ENGLISH.has(word.toLowerCase()))
263
+ return true;
264
+ if (/[A-Z]/.test(word.slice(1)))
265
+ return true;
266
+ return word.length > 1 && /[A-Z]/.test(word) && word === word.toUpperCase();
267
+ }
268
+ /** First letter upper case when it is a lower-case letter; the rest as written. Identifiers stay as written. */
269
+ function sentenceCase(label) {
270
+ const first = label.charAt(0);
271
+ if (first === first.toUpperCase())
272
+ return label;
273
+ if (looksLikeIdentifier(label.split(/\s/, 1)[0] ?? ''))
274
+ return label;
275
+ return first.toUpperCase() + label.slice(1);
276
+ }
241
277
  export async function inkAwareChoose(a) {
242
278
  const { shouldUseInk, isHeadless } = await import('../renderer/tty.js');
243
279
  if (isHeadless())
244
280
  return { kind: 'skip' };
245
281
  if (!shouldUseInk())
246
282
  return { kind: 'write' }; // classic REPL → free-text fallback
247
- const choices = [
248
- ...a.options.map((o) => ({ value: o, label: o })),
249
- { value: CHOOSE_WRITE, label: '✏️ Write my own…' },
250
- { value: CHOOSE_SKIP, label: '↷ Skip (leave for the viewer)' },
283
+ const header = `Blocker ${a.index + 1} of ${a.total}`;
284
+ const freeText = a.options.length === 0;
285
+ const options = freeText ? [] : [
286
+ ...a.options.map((o, i) => ({ value: `o${i}`, label: sentenceCase(o) })),
287
+ { value: CHOOSE_SKIP, label: SKIP_LABEL, description: SKIP_DESCRIPTION },
251
288
  ];
252
289
  const { askQuestionEmitter } = await import('../ui/ask-question-emitter.js');
253
- const res = await askQuestionEmitter.request({
254
- id: 'answer-blocker',
255
- question: `Blocker ${a.index + 1}/${a.total}: ${a.question}`,
256
- context: a.multiSelect ? 'Space to toggle one or more · Enter to submit' : undefined,
257
- kind: a.multiSelect ? 'multi' : 'choice',
258
- choices,
259
- });
260
- if (res.cancelled || res.answer == null)
290
+ const { withFocusClaim } = await import('../ui/card-slot.js');
291
+ const { emitTrace, formatAnswerTrace } = await import('../renderer/trace.js');
292
+ const { uiWidth } = await import('../renderer/ui-width.js');
293
+ let stopped = false;
294
+ const res = await withFocusClaim(() => askQuestionEmitter.requestForm({
295
+ formId: 'answer-blocker',
296
+ // Task 4 (m4): the counter sits on the frame's top edge.
297
+ frameHeader: header,
298
+ // Task 4 (m2): Ctrl+C stops the remaining blocker cards.
299
+ onInterrupt: () => { stopped = true; },
300
+ questions: [{
301
+ id: 'answer-blocker',
302
+ question: a.question,
303
+ ...(freeText ? { context: 'Esc skips it for now' } : {}),
304
+ header,
305
+ multiSelect: !freeText && a.multiSelect,
306
+ freeText,
307
+ options,
308
+ }],
309
+ }));
310
+ const skip = () => {
311
+ emitTrace(formatAnswerTrace(header, SKIPPED_TRACE, uiWidth()));
261
312
  return { kind: 'skip' };
262
- const values = res.answer.split(',');
263
- if (values.includes(CHOOSE_WRITE))
264
- return { kind: 'write' };
265
- if (values.includes(CHOOSE_SKIP))
266
- return { kind: 'skip' };
267
- const real = values.filter((v) => v !== CHOOSE_WRITE && v !== CHOOSE_SKIP);
268
- if (real.length === 0)
269
- return { kind: 'skip' };
270
- return { kind: 'picked', answer: real.join(', ') };
313
+ };
314
+ if (stopped) {
315
+ emitTrace(formatAnswerTrace(header, STOPPED_TRACE, uiWidth()));
316
+ return { kind: 'stop' };
317
+ }
318
+ const ans = res.cancelled ? undefined : res.answers[0];
319
+ if (!ans || ans.answer == null)
320
+ return skip();
321
+ let answer;
322
+ let shown;
323
+ if (freeText) {
324
+ answer = ans.answer.trim();
325
+ shown = answer;
326
+ }
327
+ else if (ans.custom) {
328
+ // Task 4 (m3): a multi-select typed answer arrives as the ticked labels
329
+ // (in option order), then the typed words. The labels go back to the
330
+ // options as written; a ticked "Skip for now" gives way to the answer.
331
+ let rest = ans.answer.trim();
332
+ const parts = [];
333
+ const shownParts = [];
334
+ if (a.multiSelect) {
335
+ for (const o of options) {
336
+ if (!rest.startsWith(`${o.label}, `))
337
+ continue;
338
+ rest = rest.slice(o.label.length + 2);
339
+ const i = /^o(\d+)$/.exec(o.value);
340
+ if (i) {
341
+ parts.push(a.options[Number(i[1])]);
342
+ shownParts.push(o.label);
343
+ }
344
+ }
345
+ }
346
+ answer = [...parts, rest].join(', ');
347
+ shown = [...shownParts, rest].join(', ');
348
+ }
349
+ else {
350
+ const values = ans.answer.split(',');
351
+ if (values.includes(CHOOSE_SKIP))
352
+ return skip();
353
+ const picked = values
354
+ .map((v) => /^o(\d+)$/.exec(v))
355
+ .map((m) => (m ? a.options[Number(m[1])] : undefined))
356
+ .filter((o) => o !== undefined);
357
+ answer = picked.join(', ');
358
+ shown = picked.map(sentenceCase).join(', ');
359
+ }
360
+ if (answer.length === 0)
361
+ return skip();
362
+ emitTrace(formatAnswerTrace(header, shown, uiWidth()));
363
+ return { kind: 'picked', answer };
271
364
  }
272
365
  export async function maybeOfferSave(p) {
273
366
  if (p.assistantText.trim().length === 0)
@@ -822,9 +915,12 @@ ${imageExpansion.error}`));
822
915
  // Standalone hides the SAP tools (toolsForContext); the agent_run
823
916
  // read-only filter (toolFilter) applies before the split, so a filtered-out
824
917
  // tool is neither loaded nor deferred (cannot be found by search either).
918
+ // Azure tenant mode (Task 1) — this connection's wire capabilities.
919
+ const profile = profileOf(params.provider);
825
920
  session.tool_set_skill ??= params.skill;
826
921
  const toolLoadingMode = resolveToolLoading({
827
922
  providerMode: params.provider.mode,
923
+ profile,
828
924
  env: process.env,
829
925
  served: servedToolLoading(),
830
926
  });
@@ -849,7 +945,7 @@ ${imageExpansion.error}`));
849
945
  // fetch, a model off the allow-list) has its deferred shapes stripped ONCE
850
946
  // (a deliberate rewrite like /compact) and is re-pinned static.
851
947
  const callToolMode = (model) => {
852
- const settled = settleSessionToolLoading(session, { resolved: toolLoadingMode, model });
948
+ const settled = settleSessionToolLoading(session, { resolved: toolLoadingMode, model: familyOf(model, params.provider) });
853
949
  if (settled.stripped > 0) {
854
950
  emit(chalk.dim('(this session now sends the full tool list — earlier tool-search steps were removed from the conversation)'));
855
951
  }
@@ -1040,6 +1136,9 @@ ${imageExpansion.error}`));
1040
1136
  // Final review C1/I2 + N1 (b) — the turn's mode, settled at turn
1041
1137
  // start (any one-time deferred → static strip ran there).
1042
1138
  const model = sentModel();
1139
+ // Azure tenant mode (Task 2) — capability lookups take the FAMILY;
1140
+ // `model` stays the wire name. Identity for every other connection.
1141
+ const family = familyOf(model, params.provider);
1043
1142
  const tools = turnTools;
1044
1143
  // Bug 11a (proactive heal, 2026-06-07) — strip any residual
1045
1144
  // `partial_json` from tool_use blocks on EVERY outgoing request.
@@ -1088,7 +1187,11 @@ ${imageExpansion.error}`));
1088
1187
  // Task 4 — effort only from an explicit act (ruling F2); never to a
1089
1188
  // model that rejects it (probe P1: claude-haiku-4-5* ⇒ 400).
1090
1189
  const effort = params.effortOverride ?? resolveEffort(cfg);
1091
- const sendEffort = effort !== undefined && modelAcceptsEffort(model);
1190
+ // Azure tenant mode (Task 5a) — deployment connections use the
1191
+ // effort ALLOW-list: an unknown deployment name maps to itself and
1192
+ // would pass the deny-list. Model-id connections are unchanged.
1193
+ const familyTakesEffort = profile.modelIdentity === 'deployment' ? familyAcceptsEffort(family) : modelAcceptsEffort(family);
1194
+ const sendEffort = effort !== undefined && familyTakesEffort && sendsEffortFor(profile, params.modelRole);
1092
1195
  const streamParams = {
1093
1196
  // A4 — per-turn override (plan model tiering) wins over the
1094
1197
  // session model; absent on every non-plan turn. Task 3 (2026-09-27)
@@ -1116,7 +1219,7 @@ ${imageExpansion.error}`));
1116
1219
  // I1 (2026-09-28) — clamped to the sent model's output ceiling:
1117
1220
  // an agent_run child asks for 100000, which an enforced
1118
1221
  // sonnet/haiku model rejects with a 400.
1119
- max_tokens: clampMaxTokens(model, params.maxTokensOverride ?? sessionMaxTokens(model)),
1222
+ max_tokens: clampMaxTokens(family, params.maxTokensOverride ?? sessionMaxTokens(family)),
1120
1223
  messages: session.messages,
1121
1224
  tools,
1122
1225
  // Task 7 — preserved thinking. Opus 5.5 binds each thinking block
@@ -1125,9 +1228,10 @@ ${imageExpansion.error}`));
1125
1228
  // input_transformations) instead of rejecting the request. Only to
1126
1229
  // the families probe P4 proved (allow-list, dated ids stripped).
1127
1230
  // Task 21 fix round (controller ruling) — on the managed path only
1128
- // when the proxy serves tool_loading (an old proxy adds no binding
1231
+ // when the proxy serves tool_loading — the managed profile's
1232
+ // 'allow-list-when-proxy-serves' rule (an old proxy adds no binding
1129
1233
  // beta); BYOK adds its own beta; AI-hub/local strip the field.
1130
- thinking: modelAcceptsBinding(model) && (params.provider.mode !== 'managed' || managedProxyServesNewShapes())
1234
+ thinking: sendsBlockBinding(profile, family, managedProxyServesNewShapes)
1131
1235
  ? { type: 'adaptive', display: 'summarized', block_binding: { prefix_mismatch_behavior: 'drop_block' } }
1132
1236
  : { type: 'adaptive', display: 'summarized' },
1133
1237
  ...(sendEffort ? { output_config: { effort } } : {}),
@@ -1208,6 +1312,10 @@ ${imageExpansion.error}`));
1208
1312
  // reported in `message_start` (may differ from the requested id).
1209
1313
  const callIndex = roundIndex++;
1210
1314
  let lastServedModel;
1315
+ // Security batch item 3 — this call's provider message id (usage request id).
1316
+ let lastMessageId;
1317
+ // F2 — a call that never saw message_start ran nothing: no usage record.
1318
+ let sawMessageStart = false;
1211
1319
  // 2026-09-27 — blocks whose content_block_stop arrived. A thinking block
1212
1320
  // not in here was cut mid-stream and must never be persisted.
1213
1321
  const completedBlocks = new WeakSet();
@@ -1345,6 +1453,12 @@ ${imageExpansion.error}`));
1345
1453
  const servedModel = event.message?.model;
1346
1454
  if (typeof servedModel === 'string' && servedModel)
1347
1455
  lastServedModel = servedModel;
1456
+ // Security batch item 3 — the provider's message id becomes the usage
1457
+ // record's request id (the server drops resends by it).
1458
+ sawMessageStart = true;
1459
+ const messageId = event.message?.id;
1460
+ if (typeof messageId === 'string' && messageId)
1461
+ lastMessageId = messageId;
1348
1462
  // Task 7 — present only when the binding beta was sent; [] when
1349
1463
  // nothing was dropped (summarised to undefined ⇒ no cost-line key).
1350
1464
  inputTransformations = summariseInputTransformations(event.message?.input_transformations);
@@ -1471,7 +1585,7 @@ ${imageExpansion.error}`));
1471
1585
  // unpriced. Never on a per-turn override, and never on a message carrying
1472
1586
  // a `fallback` block (a refusal fallback must not pin the fallback model
1473
1587
  // for the rest of the session). Otherwise the served id is only logged.
1474
- if (params.provider.mode === 'managed'
1588
+ if (profile.adoptServedModel
1475
1589
  && !params.modelOverride
1476
1590
  && typeof lastServedModel === 'string'
1477
1591
  && lastServedModel
@@ -1594,6 +1708,35 @@ ${imageExpansion.error}`));
1594
1708
  });
1595
1709
  readerCallsLogged = params.ctx.readerSpend?.calls ?? 0;
1596
1710
  void appendCostLine(session.id, entry);
1711
+ // Security batch item 3 — usage counts for byok / ai-hub / local (the
1712
+ // proxy counts managed, so a managed turn never reaches the reporter:
1713
+ // no config read, no file, no network). Counts only; fire-and-forget;
1714
+ // a sync throw or a rejection is swallowed so the turn never notices.
1715
+ if (params.provider.mode !== 'managed') {
1716
+ try {
1717
+ void reportCallUsage({
1718
+ mode: params.provider.mode,
1719
+ requestId: lastMessageId,
1720
+ skill: params.skill,
1721
+ // Azure tenant mode (Task 5a) — the usage `model` is the FAMILY of
1722
+ // the deployment this call was sent to (spec "Usage reporter and
1723
+ // licence"). Not the served id: Foundry's message_start model
1724
+ // string is unknown until the probe and may be a dated id, which
1725
+ // familyOf would return unchanged. Every other mode keeps today's
1726
+ // expression byte for byte.
1727
+ model: profile.modelIdentity === 'deployment'
1728
+ ? familyOf(params.modelOverride ?? session.model, params.provider)
1729
+ : (lastServedModel ?? params.modelOverride ?? session.model),
1730
+ input: callTokens.input,
1731
+ output: callTokens.output,
1732
+ cacheRead: callTokens.cacheRead,
1733
+ cacheWrite: callTokens.cacheCreate,
1734
+ sessionId: session.id,
1735
+ started: sawMessageStart,
1736
+ }).catch(() => { });
1737
+ }
1738
+ catch { /* usage counts are best effort */ }
1739
+ }
1597
1740
  }
1598
1741
  // Close the stream-to-disk mirror with a footer (lightweight diagnostics).
1599
1742
  // Best-effort — failure here is invisible to the turn flow. Awaited
@@ -1779,7 +1922,7 @@ ${imageExpansion.error}`));
1779
1922
  summarise: async (toSummarise) => summariseViaProvider(params.provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
1780
1923
  // model-governance step 2d — compact model resolves env > local >
1781
1924
  // server > built-in (== COMPACTION_MODEL when nothing set).
1782
- { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' } }),
1925
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' }, sessionId: session.id }),
1783
1926
  });
1784
1927
  }
1785
1928
  catch (err) {
@@ -36,6 +36,19 @@ export const BINDING_MODELS = new Set([
36
36
  export function modelAcceptsBinding(model) {
37
37
  return BINDING_MODELS.has(model.trim().toLowerCase().replace(/-\d{8}$/, ''));
38
38
  }
39
+ /**
40
+ * Azure tenant mode, Task 1 — whether the loop adds thinking.block_binding,
41
+ * read from the connection profile. Managed: allow-list AND the proxy serves
42
+ * the new shapes (an old proxy adds no binding beta). The callback is only
43
+ * called for that rule.
44
+ */
45
+ export function sendsBlockBinding(profile, model, proxyServesNewShapes) {
46
+ switch (profile.blockBinding) {
47
+ case 'off': return false;
48
+ case 'allow-list': return modelAcceptsBinding(model);
49
+ case 'allow-list-when-proxy-serves': return modelAcceptsBinding(model) && proxyServesNewShapes();
50
+ }
51
+ }
39
52
  /**
40
53
  * Task 8 (ruling F5, spec D9) — ALLOW-list of the families probe P2 proved
41
54
  * accept `max_tokens: 65536` (claude-opus-5, claude-opus-5-5). Same matching
@@ -222,6 +235,21 @@ export function toLegacyShape(params) {
222
235
  }
223
236
  return out;
224
237
  }
238
+ /**
239
+ * Azure tenant mode, Task 5a — for a connection whose profile says
240
+ * `toolChanges: false`: deferred tools load in full, the tool search entry
241
+ * and `role: 'system'` tool_addition messages (and server tool blocks) are
242
+ * removed. Unlike toLegacyShape it keeps every other field, so cache markers
243
+ * added afterwards (withSkillPrompt) still apply. Never mutates the input.
244
+ */
245
+ export function withoutToolChanges(params) {
246
+ const out = { ...params };
247
+ if (Array.isArray(params.tools))
248
+ out.tools = legacyTools(params.tools);
249
+ if (Array.isArray(params.messages))
250
+ out.messages = legacyMessages(params.messages);
251
+ return out;
252
+ }
225
253
  /**
226
254
  * Task 9 (Task 5 carry) — headers only our proxy understands. On a direct
227
255
  * path (BYOK talks to the public API) they would leak CSPeach plumbing to a
@@ -251,6 +279,14 @@ export function withoutRoleHeader(headers) {
251
279
  }
252
280
  return out;
253
281
  }
282
+ /** Azure tenant mode, Task 5a — the headers a profile lets leave the laptop. */
283
+ export function headersForProfile(profile, headers) {
284
+ switch (profile.stripInternalHeaders) {
285
+ case 'all': return withoutInternalHeaders(headers);
286
+ case 'role-only': return withoutRoleHeader(headers);
287
+ case 'none': return { ...(headers ?? {}) };
288
+ }
289
+ }
254
290
  /** 2026-10-04 — the plan phase's delegate skill, when the loop sent one. */
255
291
  export function delegateSkillName(options) {
256
292
  const v = options.headers?.[DELEGATE_SKILL_HEADER];
@@ -1,21 +1,22 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
2
  import keychain from '../../auth/keychain.js';
3
- import { createManifestSkillSource } from '../../skills/source-manifest.js';
4
- import { DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
3
+ import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
5
4
  import { delegateSkillName, requireSkillName, toLegacyShape, withoutRoleHeader } from '../provider-shape.js';
6
5
  import { systemPromptForSkill } from '../system-prompt.js';
6
+ import { AI_HUB_PROFILE } from '../connection-profile.js';
7
7
  const KEYCHAIN_SERVICE = 'cspeach.ai-hub';
8
8
  const KEYCHAIN_ACCOUNT = 'token';
9
- export function createAiHubProvider(cfg) {
9
+ export function createAiHubProvider(cfg, getBearer = defaultBearer) {
10
10
  if (!cfg.llm.ai_hub_base_url) {
11
11
  throw new Error('ai-hub mode requires llm.ai_hub_base_url — run: cspeach config set llm.ai_hub_base_url <url>');
12
12
  }
13
13
  const baseURL = cfg.llm.ai_hub_base_url;
14
14
  const modelAlias = cfg.llm.ai_hub_model_alias ?? 'anthropic--claude-opus-4-6';
15
- const skillSource = createManifestSkillSource(DEFAULT_MANIFEST_URL);
15
+ const skillSource = createAuthedSkillSource(cfg, getBearer);
16
16
  return {
17
17
  name: 'sap-ai-hub',
18
18
  mode: 'ai-hub',
19
+ profile: AI_HUB_PROFILE,
19
20
  skillSource,
20
21
  async createStream(params, options = {}) {
21
22
  // Task 5 (ruling F3) — AI-hub keeps today's field set only (newer
@@ -25,7 +26,7 @@ export function createAiHubProvider(cfg) {
25
26
  const skillName = requireSkillName(options, 'AI Hub');
26
27
  const token = await getToken();
27
28
  // Task 18 — the `_reader` header uses the pinned reader prompt, no lookup.
28
- const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: false }, delegateSkillName(options));
29
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: AI_HUB_PROFILE.cacheMarkers }, delegateSkillName(options));
29
30
  const request = {
30
31
  ...toLegacyShape(params),
31
32
  system,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Azure tenant mode — the customer's own Microsoft Foundry resource. The
3
+ * request goes straight from the laptop to
4
+ * https://<resource>.services.ai.azure.com/anthropic/v1/messages with an
5
+ * Entra token or the company's Azure key; our service never sees it.
6
+ *
7
+ * Every wire choice is read from the connection profile (v0 until the probe,
8
+ * azure-profile.ts): cache markers, betas, block binding, tool changes,
9
+ * effort (main role only, and only for a family on the effort allow-list),
10
+ * keepalive and which internal headers are stripped. Fallbacks are always
11
+ * dropped (Foundry has none). The host guard runs before any client exists
12
+ * and again on every request URL (the guarded fetch in azure-auth.ts).
13
+ */
14
+ import Anthropic from '@anthropic-ai/sdk';
15
+ import { sendsEffortFor } from '../connection-profile.js';
16
+ import { AZURE_PROFILE } from '../azure-profile.js';
17
+ import { createAuthedSkillSource, defaultBearer } from '../../skills/source-authed.js';
18
+ import { delegateSkillName, headersForProfile, requireSkillName, withoutToolChanges, withSkillPrompt } from '../provider-shape.js';
19
+ import { systemPromptForSkill } from '../system-prompt.js';
20
+ import { familyAcceptsEffort, familyFromTable } from '../../models/model-identity.js';
21
+ import { getActiveConnection } from '../../connection/cache.js';
22
+ import { azureTurnVerdict, AzureConnectionRefusedError } from '../../connection/state.js';
23
+ import { azureSdkOptions, selectAzureAuth } from '../../connection/azure-auth.js';
24
+ import { noteAzureCall } from '../../connection/azure-last-call.js';
25
+ const ROLE_HEADER = 'x-cspeach-model-role';
26
+ /** The role header, whatever its case (the same rule as withoutRoleHeader in provider-shape.ts). */
27
+ function roleOf(headers) {
28
+ for (const [k, v] of Object.entries(headers ?? {}))
29
+ if (k.toLowerCase() === ROLE_HEADER)
30
+ return v;
31
+ return undefined;
32
+ }
33
+ export function shapeForAzure(p, profile) {
34
+ const out = { ...p };
35
+ delete out.fallbacks;
36
+ if (profile.blockBinding === 'off' && out.thinking?.block_binding) {
37
+ const { block_binding: _drop, ...thinking } = out.thinking;
38
+ out.thinking = thinking;
39
+ }
40
+ return out;
41
+ }
42
+ /** Effort only on the roles the profile allows, and only to a family known to accept it (never an unknown deployment). */
43
+ function withAzureEffort(p, profile, role, family) {
44
+ if (p.output_config === undefined)
45
+ return p;
46
+ if (sendsEffortFor(profile, role) && familyAcceptsEffort(family))
47
+ return p;
48
+ // Drop only effort; any other output_config field (e.g. format) stays.
49
+ const { output_config, ...rest } = p;
50
+ const { effort: _drop, ...others } = output_config;
51
+ return Object.keys(others).length > 0 ? { ...rest, output_config: others } : rest;
52
+ }
53
+ export function createAzureProvider(cfg, getBearer = defaultBearer, deps = {}) {
54
+ const profile = deps.profile ?? AZURE_PROFILE;
55
+ if (profile.shaping !== 'direct')
56
+ throw new Error(`The azure provider shapes requests itself (direct); profile says ${profile.shaping}.`);
57
+ const skillSource = createAuthedSkillSource(cfg, getBearer);
58
+ const current = () => (deps.connection ?? getActiveConnection)();
59
+ const authFor = deps.auth ?? ((c) => selectAzureAuth(c));
60
+ async function shapeRequest(params, options) {
61
+ const cached = current();
62
+ const v = azureTurnVerdict(cached);
63
+ if (!v.allow)
64
+ throw new AzureConnectionRefusedError(v.message);
65
+ const conn = cached.connection;
66
+ const skillName = requireSkillName(options, 'Azure');
67
+ const system = await systemPromptForSkill(skillName, skillSource, { cacheMarkers: profile.cacheMarkers }, delegateSkillName(options));
68
+ const base = profile.toolChanges ? params : withoutToolChanges(params);
69
+ const family = familyFromTable(params.model, conn.deployments);
70
+ const body = withAzureEffort(shapeForAzure(withSkillPrompt(base, system, { cacheMarkers: profile.cacheMarkers }), profile), profile, roleOf(options.headers), family);
71
+ // The guard runs inside azureSdkOptions, before any client exists, and
72
+ // its fetch re-checks every real request URL.
73
+ const authProvider = await authFor(conn);
74
+ const auth = await authProvider.getAuth();
75
+ const sdk = new Anthropic({ ...azureSdkOptions(conn.endpoint, auth), defaultHeaders: headersForProfile(profile, options.headers) });
76
+ noteAzureCall(params.model, authProvider.method);
77
+ return { sdk, body };
78
+ }
79
+ const withBetas = (body) => (profile.betas.length > 0 ? { ...body, betas: [...profile.betas] } : body);
80
+ const provider = {
81
+ name: 'azure-foundry',
82
+ mode: 'azure',
83
+ profile,
84
+ skillSource,
85
+ modelFamily: (wire) => familyFromTable(wire, current()?.connection.deployments ?? null),
86
+ async createStream(params, options = {}) {
87
+ const { sdk, body } = await shapeRequest(params, options);
88
+ const opts = options.signal ? { signal: options.signal } : undefined;
89
+ return profile.betas.length > 0
90
+ ? sdk.beta.messages.stream(withBetas(body), opts)
91
+ : sdk.messages.stream(body, opts);
92
+ },
93
+ async healthCheck() {
94
+ const cached = current();
95
+ if (!azureTurnVerdict(cached).allow)
96
+ return { ok: false, message: "Your company's Azure settings are not ready on this machine", hint: 'cspeach login' };
97
+ try {
98
+ await (await authFor(cached.connection)).getAuth();
99
+ }
100
+ catch (e) {
101
+ return { ok: false, message: e?.message ?? 'Azure sign-in failed', hint: cached.connection.sign_in.includes('entra') ? 'cspeach login azure' : 'cspeach config set llm.azure_key -' };
102
+ }
103
+ try {
104
+ await skillSource.listAvailableSkills();
105
+ }
106
+ catch (e) {
107
+ return { ok: false, message: `Azure sign-in ok, but skills could not be loaded: ${e.message}`, hint: 'cspeach login' };
108
+ }
109
+ return { ok: true, message: `Azure settings ok (${cached.connection.endpoint}) · deployments are checked below` };
110
+ },
111
+ };
112
+ if (profile.keepalive) {
113
+ // Same shaped body as createStream, max_tokens 0, no stream, no retries.
114
+ provider.keepalive = async (params, options = {}) => {
115
+ const { sdk, body } = await shapeRequest(params, options);
116
+ const ping = { ...withBetas(body), max_tokens: 0 };
117
+ const opts = { maxRetries: 0, timeout: 20_000 };
118
+ if (profile.betas.length > 0)
119
+ await sdk.beta.messages.create(ping, opts);
120
+ else
121
+ await sdk.messages.create(ping, opts);
122
+ };
123
+ }
124
+ return provider;
125
+ }