@cspeach/cli 1.1.32 → 1.1.34

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 (101) 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 +61 -8
  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 +25 -8
  33. package/dist/commands/help.js +2 -1
  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 +112 -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/month-usage.js +45 -0
  62. package/dist/cost/router-spend.js +3 -0
  63. package/dist/cost/session-cost.js +4 -0
  64. package/dist/doctor/checks/azure.js +100 -0
  65. package/dist/doctor/checks/credits.js +4 -3
  66. package/dist/doctor/checks/usage-reporting.js +22 -0
  67. package/dist/doctor/run.js +7 -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/renderer/footer-line.js +7 -1
  76. package/dist/renderer/status-footer.js +13 -6
  77. package/dist/repl/ask-or-no.js +18 -0
  78. package/dist/repl/builtin-commands.js +12 -1
  79. package/dist/repl/post-turn-status.js +3 -2
  80. package/dist/repl/safety-confirm.js +47 -2
  81. package/dist/repl/session-spend-line.js +4 -1
  82. package/dist/repl/slash-completer.js +1 -0
  83. package/dist/repl/startup-account.js +51 -0
  84. package/dist/repl.js +223 -48
  85. package/dist/router/byok-classifier.js +41 -14
  86. package/dist/router/classifier.js +5 -16
  87. package/dist/router/router-target.js +41 -0
  88. package/dist/sap/onboarding.js +70 -64
  89. package/dist/session/audit-export.js +11 -4
  90. package/dist/skills/manifest-client.js +37 -4
  91. package/dist/skills/skills-cache.js +115 -0
  92. package/dist/skills/source-authed.js +276 -0
  93. package/dist/tools/filesystem/glob.js +12 -3
  94. package/dist/tools/filesystem/grep.js +14 -3
  95. package/dist/tools/tool-loading.js +9 -10
  96. package/dist/usage/usage-queue.js +323 -0
  97. package/dist/usage/usage-reporter.js +270 -0
  98. package/package.json +3 -2
  99. package/dist/skills/bundled-skills.js +0 -286
  100. package/dist/skills/source-bundled.js +0 -20
  101. package/dist/skills/source-manifest.js +0 -36
@@ -0,0 +1,94 @@
1
+ export const AZURE_ENDPOINT_RE = /^https:\/\/[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\.services\.ai\.azure\.com\/anthropic$/;
2
+ const AZURE_HOST_RE = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\.services\.ai\.azure\.com$/;
3
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ /** Same rule as the proxy (customer-connections.ts DEPLOYMENT_NAME_RE): 1–64 of A-Z a-z 0-9 . _ -, not only dots. */
5
+ const NAME_RE = /^(?!\.+$)[A-Za-z0-9._-]{1,64}$/;
6
+ const FAMILY_RE = /^claude-[a-z0-9-]{1,60}$/;
7
+ export class AzureHostRefusedError extends Error {
8
+ host;
9
+ constructor(host) {
10
+ super(`CSPeach will not send your sign-in to ${host}.`);
11
+ this.name = 'AzureHostRefusedError';
12
+ this.host = host;
13
+ }
14
+ }
15
+ export function isAllowedAzureEndpoint(endpoint) {
16
+ return typeof endpoint === 'string' && AZURE_ENDPOINT_RE.test(endpoint);
17
+ }
18
+ export function assertAllowedAzureUrl(url) {
19
+ let u;
20
+ try {
21
+ u = new URL(url);
22
+ }
23
+ catch {
24
+ throw new AzureHostRefusedError(String(url).slice(0, 80));
25
+ }
26
+ const pathOk = u.pathname === '/anthropic' || u.pathname.startsWith('/anthropic/');
27
+ if (u.protocol !== 'https:' || u.port !== '' || u.username !== '' || u.password !== '' || !AZURE_HOST_RE.test(u.hostname) || !pathOk) {
28
+ throw new AzureHostRefusedError(u.host || String(url).slice(0, 80));
29
+ }
30
+ }
31
+ function entry(v) {
32
+ if (!v || typeof v !== 'object' || Array.isArray(v))
33
+ return null;
34
+ const r = v;
35
+ return typeof r.name === 'string' && NAME_RE.test(r.name) && typeof r.model === 'string' && FAMILY_RE.test(r.model)
36
+ ? { name: r.name, model: r.model } : null;
37
+ }
38
+ /** A settings answer the CLI accepts, or null (treated like a failed fetch: the cache is kept). */
39
+ export function parseConnectionAnswer(raw) {
40
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
41
+ return null;
42
+ const r = raw;
43
+ if (r.kind === 'none')
44
+ return { kind: 'none' };
45
+ if (r.kind !== 'azure')
46
+ return null;
47
+ if (!isAllowedAzureEndpoint(r.endpoint))
48
+ return null;
49
+ if (typeof r.entra_tenant_id !== 'string' || !UUID_RE.test(r.entra_tenant_id))
50
+ return null;
51
+ const s = r.sign_in;
52
+ if (!Array.isArray(s) || s.length === 0 || !s.every((x) => x === 'entra' || x === 'key'))
53
+ return null;
54
+ const d = r.deployments;
55
+ if (!d || typeof d !== 'object')
56
+ return null;
57
+ const main = entry(d.main);
58
+ const small = entry(d.small);
59
+ const review = d.review === undefined ? undefined : entry(d.review);
60
+ if (!main || !small || review === null)
61
+ return null;
62
+ const usage = r.usage_counts === undefined ? 'required' : r.usage_counts;
63
+ if (usage !== 'required' && usage !== 'optional')
64
+ return null;
65
+ if (typeof r.updated_at !== 'string')
66
+ return null;
67
+ const deployments = review ? { main, small, review } : { main, small };
68
+ const limit = seatLimit(r.limit);
69
+ const out = {
70
+ kind: 'azure', endpoint: r.endpoint, entra_tenant_id: r.entra_tenant_id.toLowerCase(),
71
+ sign_in: [...new Set(s)], deployments, usage_counts: usage, updated_at: r.updated_at,
72
+ ...(limit ? { limit } : {}),
73
+ };
74
+ return out;
75
+ }
76
+ const LIMIT_MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
77
+ /**
78
+ * Task 8b — this seat's monthly limit, only when well-formed: a positive whole
79
+ * limit, a whole non-negative `used`, a UTC month 'YYYY-MM'. Anything else is
80
+ * ignored (no limit applied, no crash); the rest of the settings still parse.
81
+ */
82
+ function seatLimit(v) {
83
+ if (!v || typeof v !== 'object' || Array.isArray(v))
84
+ return undefined;
85
+ const l = v;
86
+ const { monthly_tokens: m, used: u, month } = l;
87
+ if (typeof m !== 'number' || !Number.isInteger(m) || m <= 0)
88
+ return undefined;
89
+ if (typeof u !== 'number' || !Number.isInteger(u) || u < 0)
90
+ return undefined;
91
+ if (typeof month !== 'string' || !LIMIT_MONTH_RE.test(month))
92
+ return undefined;
93
+ return { monthly_tokens: m, used: u, month };
94
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Azure tenant mode, Task 8b — the per-developer monthly limit, applied by
3
+ * CSPeach on the laptop against the counts it reports (Azure budgets and
4
+ * quotas are the hard stop). Checked before each NEW turn; a running turn
5
+ * finishes. One 80% line per session. Offline: the last known `used` plus the
6
+ * local count. The month is UTC.
7
+ */
8
+ import { formatFailure } from '../renderer/notice-shape.js';
9
+ import { utcMonth } from './local-usage.js';
10
+ export { countedTokens } from './local-usage.js';
11
+ export const WARN_AT = 0.8;
12
+ export function limitVerdict(limit, local, now, warned) {
13
+ if (!limit)
14
+ return { allow: true };
15
+ const month = utcMonth(now);
16
+ // A `used` from an earlier month is not this month's: a new UTC month starts at 0.
17
+ const used = limit.month === month ? limit.used : 0;
18
+ const mine = local && local.month === month ? local.tokens : 0;
19
+ const total = used + mine;
20
+ if (total >= limit.monthly_tokens) {
21
+ return { allow: false, message: formatFailure({ what: "You have reached your company's CSPeach limit for this month.", next: 'ask your CSPeach admin to raise it' }).trimEnd() };
22
+ }
23
+ if (!warned && total >= WARN_AT * limit.monthly_tokens)
24
+ return { allow: true, warn: "You have used 80% of this month's CSPeach limit." };
25
+ return { allow: true };
26
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Azure tenant mode, Task 8b — <CSPEACH_HOME>/azure-usage-local.json: counted
3
+ * tokens since the last good limit refresh, in the current UTC month.
4
+ * Survives restarts, so the limit holds offline (plan D6). A damaged or
5
+ * hand-edited file reads as "no local count", never a crash.
6
+ */
7
+ import { promises as fs } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { cspeachRoot } from '../config/paths.js';
10
+ /**
11
+ * The tokens a limit counts: input + output + cache-write. Cache reads are
12
+ * not counted. The one place the CLI computes it (the proxy's seat total,
13
+ * seatMonthTokens in cspeach-proxy/src/db/azure-usage.ts, sums the same three).
14
+ */
15
+ export function countedTokens(r) {
16
+ return (r.input_tokens || 0) + (r.output_tokens || 0) + (r.cache_write_tokens || 0);
17
+ }
18
+ const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
19
+ export function utcMonth(d) {
20
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
21
+ }
22
+ export function localUsagePath() {
23
+ return path.join(cspeachRoot(), 'azure-usage-local.json');
24
+ }
25
+ export async function readLocalUsage() {
26
+ try {
27
+ const r = JSON.parse(await fs.readFile(localUsagePath(), 'utf-8'));
28
+ if (!r || typeof r.month !== 'string' || !MONTH_RE.test(r.month) || typeof r.since !== 'string'
29
+ || typeof r.tokens !== 'number' || !Number.isFinite(r.tokens) || r.tokens < 0)
30
+ return null;
31
+ return { month: r.month, since: r.since, tokens: Math.floor(r.tokens) };
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ async function write(u) {
38
+ const f = localUsagePath();
39
+ await fs.mkdir(path.dirname(f), { recursive: true });
40
+ const tmp = `${f}.${process.pid}.tmp`;
41
+ await fs.writeFile(tmp, JSON.stringify(u), 'utf-8');
42
+ await fs.rename(tmp, f);
43
+ }
44
+ export async function addLocalUsage(tokens, now = new Date()) {
45
+ if (!Number.isFinite(tokens) || tokens <= 0)
46
+ return;
47
+ const month = utcMonth(now);
48
+ const cur = await readLocalUsage();
49
+ const base = cur && cur.month === month ? cur : { month, since: now.toISOString(), tokens: 0 };
50
+ await write({ ...base, tokens: base.tokens + Math.floor(tokens) });
51
+ }
52
+ /**
53
+ * A good limit refresh restarts the count. D6: it restarts at `tokens` — this
54
+ * seat's records still queued, which the fresh `used` does not hold yet.
55
+ */
56
+ export async function resetLocalUsage(now = new Date(), tokens = 0) {
57
+ const t = Number.isFinite(tokens) ? Math.max(0, Math.floor(tokens)) : 0;
58
+ await write({ month: utcMonth(now), since: now.toISOString(), tokens: t });
59
+ }
60
+ /** Logout (final fix wave E): remove this home's local count. True when a file was removed. */
61
+ export async function deleteLocalUsage() {
62
+ try {
63
+ await fs.unlink(localUsagePath());
64
+ return true;
65
+ }
66
+ catch (e) {
67
+ if (e?.code === 'ENOENT')
68
+ return false;
69
+ throw e;
70
+ }
71
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Azure tenant mode — the connection step of `cspeach login`, after the
3
+ * CSPeach key is stored. { kind: 'none' } leaves everything as it was. An
4
+ * Azure answer is shown (endpoint + tenant) and saved only on a yes; then
5
+ * llm.mode = "azure" is written. Task 6 adds the Microsoft sign-in after it.
6
+ */
7
+ import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
8
+ import { panelHeading, panelRow } from '../renderer/panel-rows.js';
9
+ import { fetchConnection } from './fetch.js';
10
+ import { readConnectionCache, writeConnectionCache, deleteConnectionCache } from './cache.js';
11
+ import { freshCache } from './state.js';
12
+ async function setModeAzure() {
13
+ const { loadConfig, saveConfig } = await import('../config/loader.js');
14
+ const { markExplicit } = await import('../commands/config-set.js');
15
+ const cfg = await loadConfig();
16
+ cfg.llm = { ...cfg.llm, mode: 'azure' };
17
+ markExplicit(cfg, 'llm.mode');
18
+ await saveConfig(cfg);
19
+ }
20
+ export async function runConnectionStep(d) {
21
+ const f = await (d.fetch ?? fetchConnection)({ proxyUrl: d.proxyUrl, apiKey: d.apiKey });
22
+ // No such route (an older service): silent, changes nothing — its own result.
23
+ if (!f.ok && f.status === 404)
24
+ return { result: 'no_route' };
25
+ if (!f.ok) {
26
+ d.print(formatInfo(`Could not read your company's CSPeach settings (${f.status === 0 ? f.error : `HTTP ${f.status}`}) · sign-in still worked · if your company uses its own Azure, run cspeach login again`).trimEnd());
27
+ return { result: 'unavailable' };
28
+ }
29
+ if (f.answer.kind === 'none')
30
+ return { result: 'none' };
31
+ const conn = f.answer;
32
+ d.print('');
33
+ d.print(panelHeading('Your company set CSPeach to use its own Azure'));
34
+ d.print(panelRow('Endpoint', conn.endpoint, 9));
35
+ d.print(panelRow('Tenant', conn.entra_tenant_id, 9));
36
+ if (!(await d.confirm("Is this your company's Azure endpoint?"))) {
37
+ d.print(formatFailure({ what: 'CSPeach did not switch to your Azure.', next: 'ask your CSPeach admin to check the endpoint, then run cspeach login again' }).trimEnd());
38
+ // Final fix wave A: our service refuses this company's seats on the
39
+ // managed path, so say plainly that nothing runs until this is sorted.
40
+ d.print(formatInfo("Your CSPeach mode is unchanged. CSPeach will not run until your company's Azure settings are confirmed.").trimEnd());
41
+ return { result: 'declined' };
42
+ }
43
+ // Fix round 1 (I2): a failed save leaves "nothing happened" — the mode is
44
+ // not switched — and says so in one plain line. Ruling 2 (Task 7): only a
45
+ // cache THIS login created is removed; good settings that were already here
46
+ // (a seat already on azure, logging in again) are kept or written back.
47
+ const write = d.write ?? writeConnectionCache;
48
+ const previous = await (d.read ?? readConnectionCache)().catch(() => null);
49
+ try {
50
+ await write(freshCache(conn, (d.now ?? (() => new Date()))().toISOString()));
51
+ await (d.setMode ?? (async () => setModeAzure()))('azure');
52
+ }
53
+ catch {
54
+ if (previous)
55
+ await write(previous).catch(() => { });
56
+ else
57
+ await (d.deleteCache ?? deleteConnectionCache)().catch(() => { });
58
+ d.print(formatFailure({ what: "Could not save your company's Azure settings on this machine.", next: 'run cspeach login again' }).trimEnd());
59
+ return { result: 'unavailable' };
60
+ }
61
+ return { result: 'azure', connection: conn };
62
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Azure tenant mode — load the company's settings at start and at the
3
+ * 4-hour licence boundaries (between turns only). Offline keeps the cache.
4
+ * A changed endpoint or tenant is shown and asked about before use;
5
+ * one-shot / CI never asks and refuses until a person confirms.
6
+ */
7
+ import { formatAttention, formatInfo } from '../renderer/notice-shape.js';
8
+ import { panelRow } from '../renderer/panel-rows.js';
9
+ import { ignoredEnvOverrides } from '../models/model-identity.js';
10
+ import { fetchConnection } from './fetch.js';
11
+ import { readConnectionCache, writeConnectionCache, setActiveConnection } from './cache.js';
12
+ import { applyRefresh, azureTurnVerdict, confirmPending } from './state.js';
13
+ import { resetLocalUsage } from './local-usage.js';
14
+ /** Loaded on demand: only azure refreshes read the usage queue. */
15
+ async function defaultQueuedTokens(apiKey) {
16
+ const q = await import('../usage/usage-queue.js');
17
+ return q.queuedTokens(q.keyPrefixFor(apiKey));
18
+ }
19
+ export async function refreshAzureConnection(d) {
20
+ let cached = await (d.read ?? readConnectionCache)();
21
+ let change = 'offline';
22
+ if (d.apiKey) {
23
+ const f = await (d.fetch ?? fetchConnection)({ proxyUrl: d.proxyUrl, apiKey: d.apiKey, timeoutMs: d.timeoutMs ?? 1500 });
24
+ if (f.ok) {
25
+ // Task 8b (plan D6) — a good refresh brings a fresh `used`, so the local
26
+ // running count restarts here, at the tokens of THIS seat's records still
27
+ // in the usage queue: those are exactly the ones `used` does not hold yet
28
+ // (D6 fix — not 0, which under-counts a backlog, and not "unchanged",
29
+ // which double counts what was acked). An unreadable queue keeps the
30
+ // count. A failed reset never blocks the refresh.
31
+ if (f.answer.kind === 'azure') {
32
+ const now = (d.now ?? (() => new Date()))();
33
+ let pending = null;
34
+ try {
35
+ pending = d.queuedTokens ? await d.queuedTokens() : await defaultQueuedTokens(d.apiKey);
36
+ }
37
+ catch {
38
+ pending = null;
39
+ }
40
+ if (pending !== null)
41
+ await (d.resetLocal ?? resetLocalUsage)(now, pending).catch(() => { });
42
+ }
43
+ const r = applyRefresh(cached, f.answer, (d.now ?? (() => new Date()))().toISOString());
44
+ change = r.change;
45
+ let next = r.next;
46
+ if (r.change === 'endpoint' && next?.pending && d.interactive) {
47
+ d.print(formatAttention({ text: "Your company's Azure endpoint changed in CSPeach" }).trimEnd());
48
+ d.print(panelRow('Was', `${next.connection.endpoint} · tenant ${next.connection.entra_tenant_id}`, 6));
49
+ d.print(panelRow('Now', `${next.pending.endpoint} · tenant ${next.pending.entra_tenant_id}`, 6));
50
+ if (await d.confirm("Is the new one your company's Azure endpoint?"))
51
+ next = confirmPending(next);
52
+ }
53
+ if (next) {
54
+ // The in-memory copy still applies this run; say once that it was not saved.
55
+ await (d.write ?? writeConnectionCache)(next).catch(() => {
56
+ d.print(formatInfo("Could not save your company's Azure settings. CSPeach will ask again at the next start.").trimEnd());
57
+ });
58
+ cached = next;
59
+ }
60
+ }
61
+ }
62
+ setActiveConnection(cached);
63
+ for (const v of ignoredEnvOverrides(d.env ?? process.env)) {
64
+ d.print(formatInfo(`${v} is ignored: your company's Azure settings choose the models.`).trimEnd());
65
+ }
66
+ return { cached, verdict: azureTurnVerdict(cached), change };
67
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Azure tenant mode — what a settings refresh does (spec "How settings reach
3
+ * the CLI"): a changed deployment name applies with no question; a changed
4
+ * endpoint or tenant waits for a person; removed settings refuse turns and
5
+ * never switch to managed on their own.
6
+ */
7
+ import { formatFailure } from '../renderer/notice-shape.js';
8
+ import { isAllowedAzureEndpoint } from './guard.js';
9
+ export function freshCache(conn, nowIso) {
10
+ return { connection: conn, confirmed: { endpoint: conn.endpoint, entra_tenant_id: conn.entra_tenant_id }, pending: null, removed: false, fetchedAt: nowIso };
11
+ }
12
+ const sameEntry = (a, b) => (a?.name ?? '') === (b?.name ?? '') && (a?.model ?? '') === (b?.model ?? '');
13
+ export function applyRefresh(cached, answer, nowIso) {
14
+ if (!cached)
15
+ return answer.kind === 'none' ? { next: null, change: 'none' } : { next: null, change: 'new' };
16
+ if (answer.kind === 'none')
17
+ return { next: { ...cached, removed: true, pending: null, fetchedAt: nowIso }, change: 'removed' };
18
+ const sameTarget = answer.endpoint === cached.confirmed.endpoint && answer.entra_tenant_id === cached.confirmed.entra_tenant_id;
19
+ if (!sameTarget)
20
+ return { next: { ...cached, pending: answer, removed: false, fetchedAt: nowIso }, change: 'endpoint' };
21
+ const c = cached.connection;
22
+ const deploymentsChanged = !sameEntry(c.deployments.main, answer.deployments.main)
23
+ || !sameEntry(c.deployments.small, answer.deployments.small)
24
+ || !sameEntry(c.deployments.review, answer.deployments.review);
25
+ const otherChanged = c.sign_in.join() !== answer.sign_in.join() || c.usage_counts !== answer.usage_counts
26
+ || JSON.stringify(c.limit ?? null) !== JSON.stringify(answer.limit ?? null) || cached.removed;
27
+ const change = deploymentsChanged ? 'deployments' : otherChanged ? 'other' : 'none';
28
+ return { next: { connection: answer, confirmed: cached.confirmed, pending: null, removed: false, fetchedAt: nowIso }, change };
29
+ }
30
+ export function confirmPending(c) {
31
+ if (!c.pending)
32
+ return c;
33
+ return { ...c, connection: c.pending, confirmed: { endpoint: c.pending.endpoint, entra_tenant_id: c.pending.entra_tenant_id }, pending: null };
34
+ }
35
+ /** Ruling 3 (Task 7): trimmed, like the licence refusals (licence-check.ts) and the login/logout lines. */
36
+ const refusal = (p) => formatFailure(p).trimEnd();
37
+ /**
38
+ * Why turns are refused with these settings ({ what, next }), or null when
39
+ * they are allowed. The one source for the turn refusal and for the doctor's
40
+ * settings row (final fix wave D).
41
+ */
42
+ export function azureRefusalReason(c) {
43
+ if (!c)
44
+ return { what: "Your company's Azure settings are not on this machine.", next: 'cspeach login (while online)' };
45
+ if (c.removed)
46
+ return { what: "Your company's Azure settings were removed from CSPeach.", next: 'cspeach login' };
47
+ if (c.pending)
48
+ return { what: "Your company's Azure endpoint changed and is waiting for you to confirm it.", next: 'start cspeach to review it, or run cspeach login' };
49
+ // Typed unknown so the guard's narrowing does not turn the refused branch into `never`.
50
+ const endpoint = c.connection.endpoint;
51
+ if (!isAllowedAzureEndpoint(endpoint)) {
52
+ let host = String(endpoint);
53
+ try {
54
+ host = new URL(host).host;
55
+ }
56
+ catch { /* keep the raw text */ }
57
+ return { what: `CSPeach will not send your sign-in to ${host}.`, next: 'cspeach login (settings changed)' };
58
+ }
59
+ return null;
60
+ }
61
+ export function azureTurnVerdict(c) {
62
+ const why = azureRefusalReason(c);
63
+ return why ? { allow: false, message: refusal(why) } : { allow: true };
64
+ }
65
+ /** A request refused before it left the laptop (settings missing, removed, unconfirmed or off the allow-list). */
66
+ export class AzureConnectionRefusedError extends Error {
67
+ constructor(message) { super(message); this.name = 'AzureConnectionRefusedError'; }
68
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -15,6 +15,7 @@ import fs from 'node:fs/promises';
15
15
  import path from 'node:path';
16
16
  import { sessionsDir } from '../config/paths.js';
17
17
  import { computeCost } from './pricing.js';
18
+ import { tokensOnlyActive } from './credits.js';
18
19
  /**
19
20
  * Task 7 — summarise `message_start.message.input_transformations` (probe P4
20
21
  * shape: `[{ type: 'thinking_dropped', path, reason }]`). Counts the
@@ -55,7 +56,8 @@ export function buildEntry(opts) {
55
56
  turn: opts.turn,
56
57
  model: opts.model,
57
58
  tokens: opts.tokens,
58
- cost: computeCost(opts.model, opts.tokens),
59
+ // Azure: no price for the customer's deployment — never consult one.
60
+ cost: tokensOnlyActive() ? 0 : computeCost(opts.model, opts.tokens),
59
61
  duration_ms: opts.duration_ms,
60
62
  };
61
63
  if (opts.credits) {
@@ -46,7 +46,7 @@ export async function initCredits(cfg, getBearer) {
46
46
  try {
47
47
  // Non-managed modes can't be metered by us — skip the round-trip entirely.
48
48
  if (cfg.llm?.mode !== 'managed') {
49
- setCostDisplayMode('usd');
49
+ setCostDisplayMode(cfg.llm?.mode === 'azure' ? 'tokens' : 'usd');
50
50
  return;
51
51
  }
52
52
  const info = await fetchBalance(cfg, getBearer);
@@ -211,6 +211,10 @@ export function activeCostDisplayMode() {
211
211
  export function creditsActive() {
212
212
  return activeMode === 'credits';
213
213
  }
214
+ /** Azure tenant mode — tokens only, never a price (the customer's Azure invoice is the record). */
215
+ export function tokensOnlyActive() {
216
+ return activeMode === 'tokens';
217
+ }
214
218
  /** Test seam — reset the process-wide mode between cases. */
215
219
  export function resetCostDisplayMode() {
216
220
  activeMode = 'usd';
@@ -6,6 +6,9 @@
6
6
  * credits; the USD number we compute locally is our Anthropic COGS and
7
7
  * must never reach their screen.
8
8
  *
9
+ * `'tokens'` — azure: the customer's own Azure bill at their rates; CSPeach
10
+ * has no price, so tokens only.
11
+ *
9
12
  * `'usd'` — everything else:
10
13
  * - byok / ai-hub / local: the customer holds the provider relationship,
11
14
  * so the dollar figure IS their bill. Showing it is the whole point.
@@ -18,7 +21,11 @@
18
21
  * Callers must treat this as the single source of truth — no site may
19
22
  * re-derive the mode from `cfg.llm.mode` alone.
20
23
  */
24
+ /** What azure mode shows wherever a price would be. */
25
+ export const AZURE_INVOICE_LINE = 'billed on your Azure invoice';
21
26
  export function costDisplayMode(cfg, balanceInfo) {
27
+ if (cfg.llm?.mode === 'azure')
28
+ return 'tokens';
22
29
  if (cfg.llm?.mode !== 'managed')
23
30
  return 'usd';
24
31
  if (cfg.show_cogs === true)
@@ -0,0 +1,45 @@
1
+ export const MONTH_USAGE_TIMEOUT_MS = 5_000;
2
+ function num(v) {
3
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
4
+ }
5
+ export async function fetchMonthUsage(cfg, getBearer, fetchFn = fetch) {
6
+ const controller = new AbortController();
7
+ const timer = setTimeout(() => controller.abort(), MONTH_USAGE_TIMEOUT_MS);
8
+ try {
9
+ const key = await getBearer();
10
+ if (!key)
11
+ return null;
12
+ const r = await fetchFn(`${cfg.proxy_url.replace(/\/$/, '')}/v1/me/usage`, {
13
+ headers: { Authorization: `Bearer ${key}` },
14
+ signal: controller.signal,
15
+ });
16
+ if (!r.ok)
17
+ return null;
18
+ const b = await r.json();
19
+ if (typeof b !== 'object' || b === null)
20
+ return null;
21
+ const o = b;
22
+ const skills = Array.isArray(o.top_skills) ? o.top_skills : [];
23
+ return {
24
+ metered: o.metered === true,
25
+ unitLabel: typeof o.unit_label === 'string' && o.unit_label ? o.unit_label : 'credits',
26
+ monthStart: typeof o.month_start === 'string' ? o.month_start : null,
27
+ monthUsed: num(o.month_used_credits),
28
+ topSkills: skills.flatMap((s) => {
29
+ const e = s;
30
+ const credits = num(e?.credits);
31
+ return typeof e?.skill === 'string' && credits !== null ? [{ skill: e.skill, credits }] : [];
32
+ }),
33
+ otherSkills: num(o.other_skills_credits) ?? 0,
34
+ balance: num(o.balance_credits),
35
+ subscription: num(o.subscription_credits),
36
+ purchased: num(o.purchased_credits),
37
+ };
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
@@ -10,8 +10,11 @@
10
10
  * (the proxy's router cost is ours, never shown).
11
11
  */
12
12
  import { computeCost } from './pricing.js';
13
+ import { tokensOnlyActive } from './credits.js';
13
14
  let usd = 0;
14
15
  export function addRouterSpend(model, usage) {
16
+ if (tokensOnlyActive())
17
+ return; // azure: no price of ours for their deployment
15
18
  usd += computeCost(model, usage);
16
19
  }
17
20
  /** USD spent on BYOK router calls in this process. */
@@ -17,6 +17,7 @@
17
17
  * ran. Same caveat as the footer's session line.
18
18
  */
19
19
  import { computeCost } from './pricing.js';
20
+ import { tokensOnlyActive } from './credits.js';
20
21
  import { routerSpendUsd } from './router-spend.js';
21
22
  /**
22
23
  * USD cost of ONE session's lifetime usage, priced at `session.model` — no
@@ -25,6 +26,9 @@ import { routerSpendUsd } from './router-spend.js';
25
26
  * spend must not ride along — review S1, 2026-10-02).
26
27
  */
27
28
  export function computeSessionModelCost(session) {
29
+ // Azure: no price for the customer's deployment — never consult one.
30
+ if (tokensOnlyActive())
31
+ return 0;
28
32
  return computeCost(session.model, {
29
33
  input: session.usage.input_tokens,
30
34
  output: session.usage.output_tokens,
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Azure tenant mode — `cspeach doctor` (spec "Commands"): settings + host,
3
+ * sign-in (silent), each deployment answers a 1-token call and the model it
4
+ * answers as is compared with the declared family; review optional; 403/404
5
+ * plain; the data-zone note. The calls cost a few tokens on the customer's
6
+ * Azure, and the first row says so.
7
+ *
8
+ * Every probe goes through the guarded Azure client (azureSdkOptions: host
9
+ * allow-list on the final URL, redirects refused), with no SDK retries.
10
+ */
11
+ import Anthropic from '@anthropic-ai/sdk';
12
+ import { probeTargets } from '../../models/model-identity.js';
13
+ import { readConnectionCache, setActiveConnection } from '../../connection/cache.js';
14
+ import { azureRefusalReason } from '../../connection/state.js';
15
+ import { azureSdkOptions, selectAzureAuth } from '../../connection/azure-auth.js';
16
+ import { azureErrorNotice } from '../../connection/azure-errors.js';
17
+ import { AZURE_FACTS } from '../../connection/azure-facts.js';
18
+ const KEY_NEXT = 'ask IT for a current key, then cspeach config set llm.azure_key -';
19
+ async function defaultCall(conn, auth, deployment) {
20
+ try {
21
+ const sdk = new Anthropic({ ...azureSdkOptions(conn.endpoint, auth), maxRetries: 0, timeout: 20_000 });
22
+ const r = await sdk.messages.create({ model: deployment, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] });
23
+ return { ok: true, model: typeof r?.model === 'string' ? r.model : null };
24
+ }
25
+ catch (err) {
26
+ return { ok: false, err };
27
+ }
28
+ }
29
+ const sameFamily = (model, family) => model === family || model.startsWith(`${family}-`);
30
+ /**
31
+ * The doctor process has no REPL start-up: make the saved settings the active
32
+ * ones so the llm-mode row (the azure provider's health check) reads them.
33
+ * Managed and the other modes: nothing is read or set.
34
+ */
35
+ export async function activateAzureForDoctor(cfg, read = readConnectionCache) {
36
+ if (cfg.llm?.mode !== 'azure')
37
+ return;
38
+ setActiveConnection(await read().catch(() => null));
39
+ }
40
+ export async function checkAzure(cfg, deps = {}) {
41
+ if (cfg.llm?.mode !== 'azure')
42
+ return [];
43
+ const cached = await (deps.cached ?? readConnectionCache)().catch(() => null);
44
+ // Final fix wave D: the same what / next as the turn refusal (missing,
45
+ // removed, waiting for confirmation, host refused).
46
+ const why = azureRefusalReason(cached);
47
+ if (why || !cached) {
48
+ const r = why ?? azureRefusalReason(null);
49
+ return [{ name: 'azure settings', ok: false, message: r.what, hint: r.next }];
50
+ }
51
+ const conn = cached.connection;
52
+ const rows = [{
53
+ name: 'azure settings', ok: true,
54
+ message: `${new URL(conn.endpoint).host} · tenant ${conn.entra_tenant_id} · the deployment checks below send a few tokens to your company's Azure`,
55
+ }];
56
+ let auth = null;
57
+ let provider = null;
58
+ try {
59
+ provider = await (deps.select ?? ((c) => selectAzureAuth(c)))(conn);
60
+ auth = await provider.getAuth();
61
+ rows.push({ name: 'azure sign-in', ok: true, message: provider.method === 'entra' ? 'Microsoft sign-in ok' : 'Azure key ok' });
62
+ }
63
+ catch (e) {
64
+ const named = e?.method;
65
+ const method = named === 'entra' || named === 'key' ? named : (provider?.method ?? 'entra');
66
+ rows.push({
67
+ name: 'azure sign-in', ok: false, message: e?.message ?? 'Azure sign-in failed',
68
+ hint: method === 'entra' ? 'cspeach login azure' : KEY_NEXT,
69
+ });
70
+ }
71
+ for (const t of probeTargets(conn.deployments)) {
72
+ const name = `azure deployment ${t.role}`;
73
+ if (!auth || !provider) {
74
+ rows.push({ name, ok: true, skipped: true, message: 'not checked: sign in first' });
75
+ continue;
76
+ }
77
+ const r = await (deps.call ?? defaultCall)(conn, auth, t.name);
78
+ if (r.ok) {
79
+ if (r.model && r.model.startsWith('claude-') && !sameFamily(r.model, t.family)) {
80
+ rows.push({ name, ok: !t.required, ...(t.required ? {} : { skipped: true }), message: `${t.name} answers as ${r.model}, but CSPeach settings say ${t.family}`, hint: 'ask IT to check the deployment, or ask us to fix the settings' });
81
+ }
82
+ else if (r.model && r.model.startsWith('claude-')) {
83
+ rows.push({ name, ok: true, message: `${t.name} answers as ${r.model}` });
84
+ }
85
+ else {
86
+ rows.push({ name, ok: true, message: `${t.name} answers (Azure ${AZURE_FACTS.messageStartModel === 'deployment-name' ? 'reports the deployment name' : 'did not report the model id'})` });
87
+ }
88
+ continue;
89
+ }
90
+ const n = azureErrorNotice(r.err, { method: provider.method, deployment: t.name });
91
+ const message = n && 'what' in n ? n.what : `${t.name} did not answer (${r.err?.message ?? 'error'})`;
92
+ const hint = n && 'next' in n ? n.next : 'run cspeach doctor again, then send us the output';
93
+ rows.push(t.required ? { name, ok: false, message, hint } : { name, ok: true, skipped: true, message, hint });
94
+ }
95
+ // A missing review is info (spec), not a warning: an ok row.
96
+ if (!conn.deployments.review)
97
+ rows.push({ name: 'azure deployment review', ok: true, message: 'no review deployment: plan reviews run on main' });
98
+ rows.push({ name: 'azure data zone', ok: true, skipped: true, message: 'CSPeach cannot check the data zone; confirm it in the Azure portal' });
99
+ return rows;
100
+ }
@@ -18,9 +18,10 @@ function defaultProbe(cfg) {
18
18
  });
19
19
  }
20
20
  export async function checkCredits(cfg, probe = defaultProbe(cfg)) {
21
- // BYOK / ai-hub / local never touch our ledger — no reason to call the proxy.
21
+ // BYOK / ai-hub / local / azure never touch our ledger — no reason to call the proxy.
22
+ // Azure Task 5b: the line says "not metered" for every non-managed mode (spec, Cost display).
22
23
  if (cfg.llm?.mode !== 'managed') {
23
- return { name: 'credits', ok: true, message: 'not metered (BYOK)' };
24
+ return { name: 'credits', ok: true, message: 'not metered' };
24
25
  }
25
26
  const info = await probe().catch(() => null);
26
27
  if (!info) {
@@ -30,7 +31,7 @@ export async function checkCredits(cfg, probe = defaultProbe(cfg)) {
30
31
  };
31
32
  }
32
33
  if (!info.metered) {
33
- return { name: 'credits', ok: true, message: 'not metered (BYOK)' };
34
+ return { name: 'credits', ok: true, message: 'not metered' };
34
35
  }
35
36
  if (info.balance_credits === null) {
36
37
  return {