@cspeach/cli 1.1.33 → 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.
- package/dist/agent/anthropic-provider.js +2 -0
- package/dist/agent/azure-profile.js +7 -0
- package/dist/agent/connection-profile.js +49 -0
- package/dist/agent/loop.js +61 -8
- package/dist/agent/provider-shape.js +36 -0
- package/dist/agent/providers/ai-hub-provider.js +6 -5
- package/dist/agent/providers/azure-provider.js +125 -0
- package/dist/agent/providers/byok-provider.js +8 -7
- package/dist/agent/providers/factory.js +8 -3
- package/dist/agent/providers/local-provider.js +18 -22
- package/dist/agent/risk-gate.js +202 -0
- package/dist/agent/summarise-via-provider.js +55 -8
- package/dist/agent/tool-dispatch.js +19 -0
- package/dist/agent/turn-error-ux.js +22 -0
- package/dist/auth/api-key.js +10 -5
- package/dist/auth/auth-file.js +35 -6
- package/dist/auth/azure/azure-key.js +21 -0
- package/dist/auth/azure/azure-log.js +45 -0
- package/dist/auth/azure/cache-store.js +171 -0
- package/dist/auth/azure/clear.js +28 -0
- package/dist/auth/azure/loopback.js +192 -0
- package/dist/auth/azure/msal-client.js +41 -0
- package/dist/auth/azure/sign-in.js +219 -0
- package/dist/auth/azure/startup-sign-in.js +64 -0
- package/dist/auth/azure/token-provider.js +116 -0
- package/dist/auth/key-resolver.js +258 -0
- package/dist/auth/me.js +11 -5
- package/dist/cli.js +21 -2
- package/dist/commands/compact.js +12 -1
- package/dist/commands/config-set.js +74 -3
- package/dist/commands/config-show.js +25 -0
- package/dist/commands/cost.js +19 -6
- package/dist/commands/help.js +2 -2
- package/dist/commands/login-azure.js +33 -0
- package/dist/commands/login.js +56 -8
- package/dist/commands/logout.js +34 -10
- package/dist/commands/plan-boundary.js +4 -4
- package/dist/commands/plan-model-tier.js +8 -3
- package/dist/commands/usage.js +6 -0
- package/dist/commands/whoami.js +113 -9
- package/dist/config/loader.js +5 -0
- package/dist/connection/azure-auth.js +135 -0
- package/dist/connection/azure-errors.js +103 -0
- package/dist/connection/azure-facts.js +21 -0
- package/dist/connection/azure-gate.js +40 -0
- package/dist/connection/azure-last-call.js +5 -0
- package/dist/connection/azure-turn-error.js +25 -0
- package/dist/connection/cache.js +70 -0
- package/dist/connection/fetch.js +33 -0
- package/dist/connection/guard.js +94 -0
- package/dist/connection/limit-gate.js +26 -0
- package/dist/connection/local-usage.js +71 -0
- package/dist/connection/login-step.js +62 -0
- package/dist/connection/startup.js +67 -0
- package/dist/connection/state.js +68 -0
- package/dist/connection/types.js +1 -0
- package/dist/cost/cost-log.js +3 -1
- package/dist/cost/credits-wire.js +1 -1
- package/dist/cost/credits.js +4 -0
- package/dist/cost/display-mode.js +7 -0
- package/dist/cost/router-spend.js +3 -0
- package/dist/cost/session-cost.js +4 -0
- package/dist/doctor/checks/azure.js +100 -0
- package/dist/doctor/checks/credits.js +4 -3
- package/dist/doctor/checks/usage-reporting.js +22 -0
- package/dist/doctor/run.js +7 -0
- package/dist/licence/licence-check.js +205 -0
- package/dist/licence/paths.js +6 -0
- package/dist/licence/turn-gate.js +66 -0
- package/dist/models/model-identity.js +65 -0
- package/dist/models/resolve.js +8 -0
- package/dist/models/server-config.js +3 -2
- package/dist/one-shot.js +86 -2
- package/dist/renderer/footer-line.js +7 -1
- package/dist/renderer/status-footer.js +13 -6
- package/dist/repl/ask-or-no.js +18 -0
- package/dist/repl/builtin-commands.js +10 -2
- package/dist/repl/post-turn-status.js +3 -2
- package/dist/repl/safety-confirm.js +47 -2
- package/dist/repl/session-spend-line.js +4 -1
- package/dist/repl/startup-account.js +51 -0
- package/dist/repl.js +211 -48
- package/dist/router/byok-classifier.js +41 -14
- package/dist/router/classifier.js +5 -16
- package/dist/router/router-target.js +41 -0
- package/dist/sap/onboarding.js +70 -64
- package/dist/session/audit-export.js +11 -4
- package/dist/skills/manifest-client.js +37 -4
- package/dist/skills/skills-cache.js +115 -0
- package/dist/skills/source-authed.js +276 -0
- package/dist/tools/filesystem/glob.js +12 -3
- package/dist/tools/filesystem/grep.js +14 -3
- package/dist/tools/tool-loading.js +9 -10
- package/dist/usage/usage-queue.js +323 -0
- package/dist/usage/usage-reporter.js +270 -0
- package/package.json +3 -2
- package/dist/skills/bundled-skills.js +0 -286
- package/dist/skills/source-bundled.js +0 -20
- package/dist/skills/source-manifest.js +0 -36
package/dist/doctor/run.js
CHANGED
|
@@ -13,6 +13,8 @@ import { checkCredits } from './checks/credits.js';
|
|
|
13
13
|
import { checkForgeRules } from './checks/forge-rules.js';
|
|
14
14
|
import { checkSystemRoles } from './checks/system-roles.js';
|
|
15
15
|
import { checkStandards } from './checks/standards.js';
|
|
16
|
+
import { checkUsageReporting } from './checks/usage-reporting.js';
|
|
17
|
+
import { checkAzure, activateAzureForDoctor } from './checks/azure.js';
|
|
16
18
|
import { paint } from '../renderer/theme.js';
|
|
17
19
|
import { getGlyphs } from '../renderer/glyphs.js';
|
|
18
20
|
import { isNoColor } from '../renderer/color-mode.js';
|
|
@@ -104,7 +106,12 @@ export async function collectChecks() {
|
|
|
104
106
|
const checks = [];
|
|
105
107
|
checks.push(await checkProxy());
|
|
106
108
|
checks.push(await checkAuth());
|
|
109
|
+
// Azure tenant mode (Task 7): the saved settings, then the azure rows right
|
|
110
|
+
// after the llm-mode row. Every other mode: no read, no rows.
|
|
111
|
+
await activateAzureForDoctor(cfg);
|
|
107
112
|
checks.push(await checkLlmMode(cfg));
|
|
113
|
+
checks.push(...(await checkAzure(cfg)));
|
|
114
|
+
checks.push(await checkUsageReporting(cfg));
|
|
108
115
|
checks.push(await checkCredits(cfg));
|
|
109
116
|
checks.push(await checkWriteMode(cfg));
|
|
110
117
|
checks.push(await checkSystemRoles(cfg));
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security batch item 1 — the shared licence check. It IS the start-up
|
|
3
|
+
* GET /v1/me (no second call), plus a cache in licence.json
|
|
4
|
+
* ({ checkedAt, serverDate, status, keyPrefix }, atomic, no secret).
|
|
5
|
+
*
|
|
6
|
+
* 401 invalid_api_key / malformed_api_key / reason 'revoked' → revoked: refuse.
|
|
7
|
+
* 401 missing_authorization → missing: "run cspeach login".
|
|
8
|
+
* network error, timeout, 5xx, 429, 404 customer_not_found… → unknown: the grace clock.
|
|
9
|
+
*
|
|
10
|
+
* Grace: 72 hours from the last `ok`. With no usable cache (fresh or wiped
|
|
11
|
+
* profile, unreadable file, rotated key) the clock starts at this failed
|
|
12
|
+
* check — only a server verdict refuses straight away. A clock earlier than
|
|
13
|
+
* the cached server Date gives "unknown" and never rewrites the cache: a
|
|
14
|
+
* deterrent, not tamper-proof.
|
|
15
|
+
*/
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { promises as fs } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { getDeviceFingerprint } from '../auth/device.js';
|
|
20
|
+
import { fetchMe } from '../auth/me.js';
|
|
21
|
+
import { formatFailure } from '../renderer/notice-shape.js';
|
|
22
|
+
import { licenceCachePath } from './paths.js';
|
|
23
|
+
export const LICENCE_GRACE_MS = 72 * 60 * 60 * 1000;
|
|
24
|
+
export const LICENCE_RECHECK_MS = 4 * 60 * 60 * 1000;
|
|
25
|
+
export const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000;
|
|
26
|
+
export function keyPrefixOf(key) { return key.slice(0, 12); }
|
|
27
|
+
export function verdictFromMe(r) {
|
|
28
|
+
if (r.ok)
|
|
29
|
+
return 'ok';
|
|
30
|
+
if (r.status === 401) {
|
|
31
|
+
if (r.error === 'missing_authorization')
|
|
32
|
+
return 'missing';
|
|
33
|
+
if (r.reason === 'revoked' || r.error === 'invalid_api_key' || r.error === 'malformed_api_key')
|
|
34
|
+
return 'revoked';
|
|
35
|
+
}
|
|
36
|
+
return 'unknown';
|
|
37
|
+
}
|
|
38
|
+
const when = (d) => d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
|
39
|
+
const offlineLine = (graceUntil) => `Could not reach CSPeach to check your licence; working offline until ${when(graceUntil)}`;
|
|
40
|
+
export function revokedMessage() {
|
|
41
|
+
return formatFailure({
|
|
42
|
+
what: 'Your CSPeach licence for this key has ended',
|
|
43
|
+
detail: [
|
|
44
|
+
'Your SAP work, sessions and snapshots are untouched.',
|
|
45
|
+
'If a plan was running, it stopped before its next step; resume it later with /abap-plan --resume.',
|
|
46
|
+
],
|
|
47
|
+
next: 'run cspeach login, or contact your admin',
|
|
48
|
+
}).trimEnd();
|
|
49
|
+
}
|
|
50
|
+
/** A 401 invalid key without the server's 'revoked' reason (e.g. mistyped or
|
|
51
|
+
* unknown key): same refusal, but nothing "ended". */
|
|
52
|
+
export function invalidKeyMessage() {
|
|
53
|
+
return formatFailure({
|
|
54
|
+
what: 'This CSPeach key is not valid',
|
|
55
|
+
detail: ['Your SAP work, sessions and snapshots are untouched.'],
|
|
56
|
+
next: 'run cspeach login, or contact your admin',
|
|
57
|
+
}).trimEnd();
|
|
58
|
+
}
|
|
59
|
+
const refusalMessage = (reason) => (reason === 'revoked' ? revokedMessage() : invalidKeyMessage());
|
|
60
|
+
function expiredMessage(lastOk) {
|
|
61
|
+
return formatFailure({
|
|
62
|
+
what: 'CSPeach could not check your licence for 72 hours',
|
|
63
|
+
detail: [
|
|
64
|
+
lastOk ? `last successful check: ${when(lastOk)}` : 'no successful check on this machine yet',
|
|
65
|
+
'Your SAP work, sessions and snapshots are untouched.',
|
|
66
|
+
],
|
|
67
|
+
next: 'connect to the network and run cspeach again, or contact your admin',
|
|
68
|
+
}).trimEnd();
|
|
69
|
+
}
|
|
70
|
+
export function missingMessage() {
|
|
71
|
+
return formatFailure({ what: 'You are not signed in to CSPeach', next: 'cspeach login' }).trimEnd();
|
|
72
|
+
}
|
|
73
|
+
export function decide(a) {
|
|
74
|
+
const nowIso = a.now.toISOString();
|
|
75
|
+
if (a.verdict === 'ok') {
|
|
76
|
+
return { decision: { allow: true, verdict: 'ok', profile: a.profile }, write: { checkedAt: nowIso, serverDate: a.serverDate, status: 'ok', keyPrefix: a.keyPrefix } };
|
|
77
|
+
}
|
|
78
|
+
if (a.verdict === 'revoked') {
|
|
79
|
+
return {
|
|
80
|
+
decision: { allow: false, verdict: 'revoked', message: refusalMessage(a.reason) },
|
|
81
|
+
write: { checkedAt: nowIso, serverDate: a.serverDate, status: 'revoked', keyPrefix: a.keyPrefix, ...(a.reason === 'revoked' ? { reason: 'revoked' } : {}) },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (a.verdict === 'missing')
|
|
85
|
+
return { decision: { allow: false, verdict: 'missing', message: missingMessage() }, write: null };
|
|
86
|
+
// A rotated key's cache, or one whose checkedAt is not a real time, is no
|
|
87
|
+
// cache: the clock starts now. It can never produce 'expired'.
|
|
88
|
+
const cache = a.cache && a.cache.keyPrefix === a.keyPrefix && Number.isFinite(Date.parse(a.cache.checkedAt)) ? a.cache : null;
|
|
89
|
+
if (!cache) {
|
|
90
|
+
const graceUntil = new Date(a.now.getTime() + LICENCE_GRACE_MS);
|
|
91
|
+
return {
|
|
92
|
+
decision: { allow: true, verdict: 'unknown', graceUntil, line: offlineLine(graceUntil), clockBehind: false },
|
|
93
|
+
write: { checkedAt: nowIso, serverDate: null, status: 'unknown', keyPrefix: a.keyPrefix },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (cache.status === 'revoked')
|
|
97
|
+
return { decision: { allow: false, verdict: 'revoked', message: refusalMessage(cache.reason) }, write: null };
|
|
98
|
+
const start = Date.parse(cache.checkedAt);
|
|
99
|
+
const anchor = Math.max(start, cache.serverDate ? Date.parse(cache.serverDate) || -Infinity : -Infinity);
|
|
100
|
+
const graceUntil = new Date(start + LICENCE_GRACE_MS);
|
|
101
|
+
const clockBehind = a.now.getTime() < anchor - CLOCK_SKEW_TOLERANCE_MS;
|
|
102
|
+
if (clockBehind || a.now.getTime() <= graceUntil.getTime()) {
|
|
103
|
+
return { decision: { allow: true, verdict: 'unknown', graceUntil, line: offlineLine(graceUntil), clockBehind }, write: null };
|
|
104
|
+
}
|
|
105
|
+
return { decision: { allow: false, verdict: 'expired', message: expiredMessage(cache.status === 'ok' ? new Date(start) : null) }, write: null };
|
|
106
|
+
}
|
|
107
|
+
export async function readLicenceCache() {
|
|
108
|
+
try {
|
|
109
|
+
const c = JSON.parse(await fs.readFile(licenceCachePath(), 'utf-8'));
|
|
110
|
+
if (typeof c.checkedAt !== 'string' || !Number.isFinite(Date.parse(c.checkedAt)))
|
|
111
|
+
return null;
|
|
112
|
+
if (c.status !== 'ok' && c.status !== 'revoked' && c.status !== 'unknown')
|
|
113
|
+
return null;
|
|
114
|
+
if (typeof c.keyPrefix !== 'string')
|
|
115
|
+
return null;
|
|
116
|
+
return {
|
|
117
|
+
checkedAt: c.checkedAt, serverDate: typeof c.serverDate === 'string' ? c.serverDate : null, status: c.status, keyPrefix: c.keyPrefix,
|
|
118
|
+
...(c.reason === 'revoked' ? { reason: 'revoked' } : {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export async function writeLicenceCache(c) {
|
|
126
|
+
const p = licenceCachePath();
|
|
127
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
128
|
+
// Random suffix, as key-resolver writeMeta: two writes in one process
|
|
129
|
+
// (start-up check + Task 6 recheck) can never share a temp file.
|
|
130
|
+
const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
131
|
+
try {
|
|
132
|
+
await fs.writeFile(tmp, JSON.stringify(c, null, 2), 'utf-8');
|
|
133
|
+
await fs.rename(tmp, p);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** One check. Never throws: anything unexpected is "unknown". */
|
|
140
|
+
export async function runLicenceCheck(a, deps = {}) {
|
|
141
|
+
let now;
|
|
142
|
+
try {
|
|
143
|
+
now = (deps.now ?? (() => new Date()))();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
now = new Date();
|
|
147
|
+
}
|
|
148
|
+
let me;
|
|
149
|
+
try {
|
|
150
|
+
me = await (deps.fetchMe ?? fetchMe)({ proxyUrl: a.proxyUrl, apiKey: a.apiKey, headers: deps.headers });
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
me = { ok: false, status: 0, error: err instanceof Error ? err.message : String(err), serverDate: null };
|
|
154
|
+
}
|
|
155
|
+
let cache = null;
|
|
156
|
+
try {
|
|
157
|
+
cache = await (deps.readCache ?? readLicenceCache)();
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
cache = null; // unreadable counts as missing — never a refusal
|
|
161
|
+
}
|
|
162
|
+
let verdict = 'unknown';
|
|
163
|
+
try {
|
|
164
|
+
verdict = verdictFromMe(me);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
verdict = 'unknown'; // a malformed answer is never a refusal
|
|
168
|
+
}
|
|
169
|
+
const reason = me && !me.ok ? me.reason : undefined;
|
|
170
|
+
let result;
|
|
171
|
+
try {
|
|
172
|
+
result = decide({
|
|
173
|
+
verdict, now, cache, keyPrefix: keyPrefixOf(a.apiKey),
|
|
174
|
+
serverDate: me.serverDate ?? null, profile: me.ok ? me.profile : undefined, reason,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Defensive only. A clear server verdict already in hand is honoured;
|
|
179
|
+
// anything else must never lock a working user out.
|
|
180
|
+
if (verdict === 'revoked')
|
|
181
|
+
return { allow: false, verdict: 'revoked', message: refusalMessage(reason) };
|
|
182
|
+
if (verdict === 'missing')
|
|
183
|
+
return { allow: false, verdict: 'missing', message: missingMessage() };
|
|
184
|
+
const graceUntil = new Date(Date.now() + LICENCE_GRACE_MS);
|
|
185
|
+
return { allow: true, verdict: 'unknown', graceUntil, line: offlineLine(graceUntil), clockBehind: false };
|
|
186
|
+
}
|
|
187
|
+
if (result.write) {
|
|
188
|
+
try {
|
|
189
|
+
await (deps.writeCache ?? writeLicenceCache)(result.write);
|
|
190
|
+
}
|
|
191
|
+
catch { /* a cache we cannot write only means the next check starts fresh */ }
|
|
192
|
+
}
|
|
193
|
+
return result.decision;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Device headers on the licence check (spec item 1), byok / ai-hub / local
|
|
197
|
+
* only — the managed /v1/me request stays byte-identical (plan deviation D2).
|
|
198
|
+
* `x-cspeach-mode` lets the proxy record the install's mode (Task 11a).
|
|
199
|
+
*/
|
|
200
|
+
export async function licenceCheckHeaders(mode, device = getDeviceFingerprint) {
|
|
201
|
+
if (mode === 'managed')
|
|
202
|
+
return {};
|
|
203
|
+
const d = await device();
|
|
204
|
+
return { 'x-device-machine-id': d.machineId, 'x-device-hostname': d.hostname, 'x-cspeach-mode': mode };
|
|
205
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { runLicenceCheck, licenceCheckHeaders, missingMessage, LICENCE_RECHECK_MS, } from './licence-check.js';
|
|
2
|
+
import { formatFailure } from '../renderer/notice-shape.js';
|
|
3
|
+
export class LicenceTurnGate {
|
|
4
|
+
o;
|
|
5
|
+
lastCheckAtMs;
|
|
6
|
+
revokedMessage = null;
|
|
7
|
+
constructor(o) {
|
|
8
|
+
this.o = o;
|
|
9
|
+
this.lastCheckAtMs = o.lastCheckAtMs ?? (o.now ?? Date.now)();
|
|
10
|
+
}
|
|
11
|
+
async beforeTurn() {
|
|
12
|
+
const lic = await this.licenceBeforeTurn();
|
|
13
|
+
if (this.o.mode === 'managed' || !lic.allow || !this.o.extra)
|
|
14
|
+
return lic;
|
|
15
|
+
let ex;
|
|
16
|
+
// Fix round 1 (I1): a throwing extra gate fails CLOSED. With removed
|
|
17
|
+
// settings the cache still holds the old connection, so allowing would
|
|
18
|
+
// send the prompt and token where the gate refuses. Not sticky: the next
|
|
19
|
+
// turn runs the gate again.
|
|
20
|
+
try {
|
|
21
|
+
ex = await this.o.extra();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return { allow: false, message: formatFailure({ what: "Could not check your company's Azure settings.", next: 'start cspeach again' }).trimEnd() };
|
|
25
|
+
}
|
|
26
|
+
if (!ex.allow)
|
|
27
|
+
return ex;
|
|
28
|
+
const line = [lic.line, ex.line].filter(Boolean).join(' · ');
|
|
29
|
+
return line ? { allow: true, line } : { allow: true };
|
|
30
|
+
}
|
|
31
|
+
async licenceBeforeTurn() {
|
|
32
|
+
if (this.o.mode === 'managed')
|
|
33
|
+
return { allow: true };
|
|
34
|
+
if (this.revokedMessage)
|
|
35
|
+
return { allow: false, message: this.revokedMessage };
|
|
36
|
+
const now = (this.o.now ?? Date.now)();
|
|
37
|
+
if (now - this.lastCheckAtMs < (this.o.intervalMs ?? LICENCE_RECHECK_MS))
|
|
38
|
+
return { allow: true };
|
|
39
|
+
this.lastCheckAtMs = now;
|
|
40
|
+
await this.o.onRecheck?.().catch(() => { });
|
|
41
|
+
const key = await this.o.getKey().catch(() => null);
|
|
42
|
+
if (!key) {
|
|
43
|
+
this.lastCheckAtMs = -Infinity;
|
|
44
|
+
return { allow: false, message: missingMessage() };
|
|
45
|
+
}
|
|
46
|
+
const headers = await (this.o.headers ?? licenceCheckHeaders)(this.o.mode).catch(() => ({}));
|
|
47
|
+
let d;
|
|
48
|
+
try {
|
|
49
|
+
d = await (this.o.check ?? runLicenceCheck)({ proxyUrl: this.o.proxyUrl, apiKey: key }, { headers });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// runLicenceCheck never throws; any other check that does must never
|
|
53
|
+
// block or crash the REPL. Allow, and check again on the next turn.
|
|
54
|
+
this.lastCheckAtMs = -Infinity;
|
|
55
|
+
return { allow: true };
|
|
56
|
+
}
|
|
57
|
+
if (!d.allow) {
|
|
58
|
+
if (d.verdict === 'revoked')
|
|
59
|
+
this.revokedMessage = d.message;
|
|
60
|
+
else
|
|
61
|
+
this.lastCheckAtMs = -Infinity;
|
|
62
|
+
return { allow: false, message: d.message };
|
|
63
|
+
}
|
|
64
|
+
return d.verdict === 'unknown' ? { allow: true, line: d.line } : { allow: true };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
let active = null;
|
|
2
|
+
export function setActiveDeployments(t) { active = t; }
|
|
3
|
+
export function getActiveDeployments() { return active; }
|
|
4
|
+
export function familyFromTable(wire, table) {
|
|
5
|
+
if (!table)
|
|
6
|
+
return wire;
|
|
7
|
+
for (const e of [table.main, table.small, table.review]) {
|
|
8
|
+
if (e && e.name === wire)
|
|
9
|
+
return e.model;
|
|
10
|
+
}
|
|
11
|
+
return wire;
|
|
12
|
+
}
|
|
13
|
+
export function familyOf(wire, provider) {
|
|
14
|
+
if (!provider || provider.profile?.modelIdentity !== 'deployment')
|
|
15
|
+
return wire;
|
|
16
|
+
return provider.modelFamily ? provider.modelFamily(wire) : familyFromTable(wire, active);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Azure tenant mode, Task 5a — families known to accept output_config.effort
|
|
20
|
+
* (an ALLOW-list, exact match after lowercasing and stripping a trailing
|
|
21
|
+
* -YYYYMMDD). Deployment connections send effort only to these; an unknown
|
|
22
|
+
* deployment name or an unprobed family (claude-haiku-*, claude-sonnet-5-5
|
|
23
|
+
* until Task 5c's probe) gets none. Model-id connections keep the
|
|
24
|
+
* modelAcceptsEffort deny-list unchanged.
|
|
25
|
+
*/
|
|
26
|
+
export const EFFORT_FAMILIES = new Set(['claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-4-6']);
|
|
27
|
+
export function familyAcceptsEffort(family) {
|
|
28
|
+
return EFFORT_FAMILIES.has(family.trim().toLowerCase().replace(/-\d{8}$/, ''));
|
|
29
|
+
}
|
|
30
|
+
/** The deployment each CLI role runs on (spec "Model roles"). */
|
|
31
|
+
export function connectionRoleModel(role, table) {
|
|
32
|
+
switch (role) {
|
|
33
|
+
case 'session_default': return table.main.name;
|
|
34
|
+
case 'audit': return (table.review ?? table.main).name;
|
|
35
|
+
case 'compact': return table.small.name;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Skill routing runs on the small deployment. */
|
|
39
|
+
export function routerDeployment(table) { return table.small; }
|
|
40
|
+
/**
|
|
41
|
+
* Plan tiering target: undefined = no connection (today's Sonnet constant);
|
|
42
|
+
* the review deployment's name; null = azure without review (tiering off).
|
|
43
|
+
*/
|
|
44
|
+
export function planTierModelFor(table) {
|
|
45
|
+
if (!table)
|
|
46
|
+
return undefined;
|
|
47
|
+
return table.review?.name ?? null;
|
|
48
|
+
}
|
|
49
|
+
/** The deployments `cspeach doctor` sends a 1-token call to (Task 7). */
|
|
50
|
+
export function probeTargets(table) {
|
|
51
|
+
const out = [
|
|
52
|
+
{ role: 'main', name: table.main.name, family: table.main.model, required: true },
|
|
53
|
+
{ role: 'small', name: table.small.name, family: table.small.model, required: true },
|
|
54
|
+
];
|
|
55
|
+
if (table.review)
|
|
56
|
+
out.push({ role: 'review', name: table.review.name, family: table.review.model, required: false });
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
export const ROLE_ENV_VARS = ['CSPEACH_DEFAULT_MODEL', 'CSPEACH_AUDIT_MODEL', 'CSPEACH_COMPACT_MODEL'];
|
|
60
|
+
/** Env role overrides that are set but ignored because the company's connection chooses the models. */
|
|
61
|
+
export function ignoredEnvOverrides(env) {
|
|
62
|
+
if (!active)
|
|
63
|
+
return [];
|
|
64
|
+
return ROLE_ENV_VARS.filter((v) => (env[v] ?? '').trim().length > 0);
|
|
65
|
+
}
|
package/dist/models/resolve.js
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
// snapshot) every role resolves to today's constant — byte-identical to before
|
|
20
20
|
// this feature.
|
|
21
21
|
//
|
|
22
|
+
// Azure tenant mode: when a connection table is active it replaces every layer.
|
|
23
|
+
//
|
|
22
24
|
// Pure in the resolveAuditModel sense: no async I/O, no fetch. The only external
|
|
23
25
|
// read is getServerModelConfig(), a synchronous in-memory snapshot the caller
|
|
24
26
|
// (or the startup fetch) has already populated. `router` is proxy-side only and
|
|
@@ -28,6 +30,7 @@ import { DEFAULT_SESSION_MODEL } from '../config/model-defaults.js';
|
|
|
28
30
|
import { PLAN_TIER_SONNET_MODEL } from '../commands/plan-model-tier.js';
|
|
29
31
|
import { COMPACTION_MODEL } from '../commands/compact.js';
|
|
30
32
|
import { getCustomerModelConfig, getServerModelConfig, isEffort } from './server-config.js';
|
|
33
|
+
import { getActiveDeployments, connectionRoleModel } from './model-identity.js';
|
|
31
34
|
/** First non-empty (trimmed) candidate in precedence order, else the built-in. */
|
|
32
35
|
function firstSet(candidates, builtin) {
|
|
33
36
|
for (const c of candidates) {
|
|
@@ -49,6 +52,11 @@ function firstSet(candidates, builtin) {
|
|
|
49
52
|
* explicit user choice (even one equal to the built-in) still wins.
|
|
50
53
|
*/
|
|
51
54
|
export function resolveModelRole(role, cfg, env = process.env) {
|
|
55
|
+
// Azure tenant mode (Task 2) — the company's deployments are the ONLY role
|
|
56
|
+
// source in azure mode (org setting wins); null for every other mode.
|
|
57
|
+
const table = getActiveDeployments();
|
|
58
|
+
if (table)
|
|
59
|
+
return connectionRoleModel(role, table);
|
|
52
60
|
const server = getServerModelConfig();
|
|
53
61
|
switch (role) {
|
|
54
62
|
case 'session_default':
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import fs from 'node:fs/promises';
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import { cspeachRoot } from '../config/paths.js';
|
|
30
|
+
import { profileForMode } from '../agent/connection-profile.js';
|
|
30
31
|
export const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
31
32
|
const EFFORT_ROLE_KEYS = ['session_default', 'audit', 'compact'];
|
|
32
33
|
export function isEffort(v) {
|
|
@@ -152,7 +153,7 @@ async function readFreshDiskCache() {
|
|
|
152
153
|
*/
|
|
153
154
|
export async function loadServerModelConfig(cfg, opts) {
|
|
154
155
|
// Non-managed modes bypass the proxy for inference and must never fetch.
|
|
155
|
-
if (cfg.llm?.mode !== '
|
|
156
|
+
if (profileForMode(cfg.llm?.mode).servedConfig !== 'full')
|
|
156
157
|
return;
|
|
157
158
|
try {
|
|
158
159
|
// URL construction lives INSIDE the try: a degenerate config (e.g. a
|
|
@@ -297,7 +298,7 @@ export function byokRouterModel() {
|
|
|
297
298
|
export async function fetchToolLoadingMode(cfg, getBearer, opts) {
|
|
298
299
|
byokToolLoading = undefined;
|
|
299
300
|
byokRouter = undefined;
|
|
300
|
-
if (cfg.llm?.mode !== '
|
|
301
|
+
if (profileForMode(cfg.llm?.mode).servedConfig !== 'router-only')
|
|
301
302
|
return undefined;
|
|
302
303
|
const controller = new AbortController();
|
|
303
304
|
const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
|
package/dist/one-shot.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { loadConfig, resolveLocalBuild, resolveStandalone } from './config/loader.js';
|
|
3
3
|
import { applyColorMode } from './renderer/color-mode.js';
|
|
4
|
-
import { formatAttention, formatInfo } from './renderer/notice-shape.js';
|
|
4
|
+
import { formatAttention, formatFailure, formatInfo } from './renderer/notice-shape.js';
|
|
5
|
+
import { azureErrorNotice, azureErrorContext } from './connection/azure-errors.js';
|
|
5
6
|
import { formatRoutingLine } from './renderer/routing-line.js';
|
|
6
7
|
import { createOfflineAdtClient } from './sap/offline-adt-client.js';
|
|
7
8
|
import { STANDALONE_ALIAS } from './tools/index.js';
|
|
@@ -14,9 +15,11 @@ import { newSession } from './session/schema.js';
|
|
|
14
15
|
import { resolveModelRole } from './models/resolve.js';
|
|
15
16
|
import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
|
|
16
17
|
import { prefetchRouterPrompt } from './router/router-prompt.js';
|
|
18
|
+
import { profileForMode } from './agent/connection-profile.js';
|
|
17
19
|
import { createProviderForMode } from './agent/providers/factory.js';
|
|
18
20
|
import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
|
|
19
21
|
import { runTurn } from './agent/loop.js';
|
|
22
|
+
import { runLicenceCheck, licenceCheckHeaders } from './licence/licence-check.js';
|
|
20
23
|
import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
|
|
21
24
|
import { routingFailureFromResult, routingFailureOneShotLine, routingFallbackFromError } from './router/routing-failure.js';
|
|
22
25
|
import { startThinkingHeartbeat, ROUTING_HEARTBEAT_THRESHOLDS } from './renderer/thinking-heartbeat.js';
|
|
@@ -32,6 +35,8 @@ import { todoEmitter } from './ui/todo-emitter.js';
|
|
|
32
35
|
import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
|
|
33
36
|
import { getEffectiveWriteMode } from './repl/mode-cycle.js';
|
|
34
37
|
import { initCredits } from './cost/credits-wire.js';
|
|
38
|
+
import { startUsageFlushAtStartup } from './usage/usage-reporter.js';
|
|
39
|
+
import { startSkillsCacheWarmup } from './skills/source-authed.js';
|
|
35
40
|
import { formatTurnReceipt, turnSpendText } from './renderer/footer-line.js';
|
|
36
41
|
import { snapshotTurnStart, computeTurnFacts } from './repl/post-turn-status.js';
|
|
37
42
|
import { globalCredits } from './cost/credits.js';
|
|
@@ -105,7 +110,7 @@ export async function runOneShot(prompt, opts) {
|
|
|
105
110
|
fetchToolLoadingMode(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
|
|
106
111
|
// 2026-10-02 — BYOK / ai-hub route on the laptop: prefetch the signed
|
|
107
112
|
// router prompt (public manifest, no prompt data) under the same cap.
|
|
108
|
-
cfg.llm?.mode
|
|
113
|
+
profileForMode(cfg.llm?.mode).router === 'direct' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
|
|
109
114
|
]);
|
|
110
115
|
// B5 (2026-06-11) — one-shot is headless by design: a single turn, no
|
|
111
116
|
// REPL, often piped (CI / battery). Any stdin prompt would hang forever
|
|
@@ -150,6 +155,74 @@ export async function runOneShot(prompt, opts) {
|
|
|
150
155
|
console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
|
|
151
156
|
return 1;
|
|
152
157
|
}
|
|
158
|
+
// Security batch item 1 — licence check at one-shot start, byok / ai-hub /
|
|
159
|
+
// local only. Managed makes no new call: the proxy checks every request.
|
|
160
|
+
if (cfg.llm.mode !== 'managed') {
|
|
161
|
+
const licence = await runLicenceCheck({ proxyUrl: cfg.proxy_url, apiKey: (await store.get()) ?? '' }, { headers: await licenceCheckHeaders(cfg.llm.mode).catch(() => ({})) });
|
|
162
|
+
if (!licence.allow) {
|
|
163
|
+
console.error(licence.message);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
if (licence.verdict === 'unknown')
|
|
167
|
+
console.error(formatInfo(licence.line).trimEnd());
|
|
168
|
+
// Azure tenant mode — one-shot never asks: a changed endpoint, removed
|
|
169
|
+
// settings or no settings refuse until a person runs cspeach interactively.
|
|
170
|
+
if (cfg.llm.mode === 'azure') {
|
|
171
|
+
const { refreshAzureConnection } = await import('./connection/startup.js');
|
|
172
|
+
const az = await refreshAzureConnection({
|
|
173
|
+
proxyUrl: cfg.proxy_url, apiKey: (await store.get()) ?? null, interactive: false,
|
|
174
|
+
print: (l) => console.error(l.trimEnd()), confirm: async () => false,
|
|
175
|
+
});
|
|
176
|
+
if (!az.verdict.allow) {
|
|
177
|
+
console.error(az.verdict.message.trimEnd());
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
// Task 6b — the Microsoft sign-in, silently: one-shot never asks and
|
|
181
|
+
// never opens a browser; a sign-in that ended refuses with the line.
|
|
182
|
+
if (az.cached) {
|
|
183
|
+
const { ensureAzureSignIn } = await import('./auth/azure/sign-in.js');
|
|
184
|
+
const s = await ensureAzureSignIn(az.cached.connection, {
|
|
185
|
+
interactive: false, ask: async () => false, print: (l) => console.error(l.trimEnd()),
|
|
186
|
+
open: async () => { throw new Error('no browser in one-shot'); },
|
|
187
|
+
});
|
|
188
|
+
if (!s.ok) {
|
|
189
|
+
console.error(s.message.trimEnd());
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
// Task 8b — the monthly limit, the same check as the REPL's per-turn
|
|
193
|
+
// gate, on the settings just loaded. A check that throws refuses with
|
|
194
|
+
// a plain line, never a crash.
|
|
195
|
+
const cached = az.cached;
|
|
196
|
+
let g;
|
|
197
|
+
try {
|
|
198
|
+
g = await (await import('./connection/azure-gate.js')).azureGateCheck(() => cached);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
g = { allow: false, message: formatFailure({ what: "Could not check this month's CSPeach limit.", next: 'try again' }) };
|
|
202
|
+
}
|
|
203
|
+
if (!g.allow) {
|
|
204
|
+
console.error(g.message.trimEnd());
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
if (g.line)
|
|
208
|
+
console.error(formatInfo(g.line).trimEnd());
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Final review finding 1 — fill the skills cache for offline use after an
|
|
212
|
+
// ok verdict. Fire-and-forget: never awaited, never throws into start-up.
|
|
213
|
+
if (licence.verdict === 'ok') {
|
|
214
|
+
try {
|
|
215
|
+
void startSkillsCacheWarmup(cfg, licence.verdict);
|
|
216
|
+
}
|
|
217
|
+
catch { /* best effort */ }
|
|
218
|
+
}
|
|
219
|
+
// Security batch item 3 — send the usage counts still waiting from earlier
|
|
220
|
+
// runs. Fire-and-forget: never awaited, never throws into start-up.
|
|
221
|
+
try {
|
|
222
|
+
startUsageFlushAtStartup(cfg);
|
|
223
|
+
}
|
|
224
|
+
catch { /* best effort */ }
|
|
225
|
+
}
|
|
153
226
|
// Credits (2026-09-16) — resolve the display mode before the turn runs, so
|
|
154
227
|
// the per-turn cost JSONL records the ledger charge and nothing downstream
|
|
155
228
|
// can print a dollar to a managed customer — including the one-line stderr
|
|
@@ -347,6 +420,17 @@ export async function runOneShot(prompt, opts) {
|
|
|
347
420
|
ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook, chunkEmitter: noticeEmitter, todoEmitter },
|
|
348
421
|
});
|
|
349
422
|
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
// Azure tenant mode (Task 6b) — the sign-in ended mid-turn: never a
|
|
425
|
+
// browser or a question; the plain line and the fix, exit 1. Task 7: the
|
|
426
|
+
// rest of the azure message table too, and a refusal the SDK wrapped as
|
|
427
|
+
// "Connection error." is unwrapped (ruling 4). Null in every other mode.
|
|
428
|
+
const azure = azureErrorNotice(err, azureErrorContext());
|
|
429
|
+
if (!azure)
|
|
430
|
+
throw err;
|
|
431
|
+
console.error(('preformatted' in azure ? azure.preformatted : formatFailure(azure)).trimEnd());
|
|
432
|
+
return 1;
|
|
433
|
+
}
|
|
350
434
|
finally {
|
|
351
435
|
// Review M2 — even when the turn throws, the answer so far ends with its
|
|
352
436
|
// newline (and any held colour reset) instead of being cut mid-line.
|
|
@@ -8,7 +8,8 @@ import { paint } from './theme.js';
|
|
|
8
8
|
import { truncateToWidth } from './ui-width.js';
|
|
9
9
|
import { sanitizeControlChars } from './sanitize.js';
|
|
10
10
|
import { formatCost } from '../cost/pricing.js';
|
|
11
|
-
import { formatCredits, CREDITS_PENDING, creditsActive } from '../cost/credits.js';
|
|
11
|
+
import { formatCredits, CREDITS_PENDING, creditsActive, tokensOnlyActive } from '../cost/credits.js';
|
|
12
|
+
import { AZURE_INVOICE_LINE } from '../cost/display-mode.js';
|
|
12
13
|
export const SHORTCUTS_HINT = '? shortcuts';
|
|
13
14
|
const SEP = ' · ';
|
|
14
15
|
/**
|
|
@@ -17,6 +18,9 @@ const SEP = ' · ';
|
|
|
17
18
|
* total, BYOK/local see the session dollars — never the other.
|
|
18
19
|
*/
|
|
19
20
|
export function formatSpendSegment(s) {
|
|
21
|
+
// Azure (Task 5b): the customer's own Azure bill — no price of ours to show.
|
|
22
|
+
if (tokensOnlyActive())
|
|
23
|
+
return AZURE_INVOICE_LINE;
|
|
20
24
|
if (!s.creditsMode)
|
|
21
25
|
return `$${s.dollarSpendToday.toFixed(2)}`;
|
|
22
26
|
if (s.creditsPending && s.creditsSessionTotal === 0)
|
|
@@ -78,6 +82,8 @@ export function formatFooterLine(s, width) {
|
|
|
78
82
|
* cost otherwise, '' when there is none.
|
|
79
83
|
*/
|
|
80
84
|
export function turnSpendText(p) {
|
|
85
|
+
if (tokensOnlyActive())
|
|
86
|
+
return '';
|
|
81
87
|
if (creditsActive()) {
|
|
82
88
|
const c = p.credits;
|
|
83
89
|
// 2026-10-02: the whole user request (every model round), not its last round.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { formatCost } from '../cost/pricing.js';
|
|
2
|
-
import { creditsActive, formatCreditsLine, CREDITS_PENDING } from '../cost/credits.js';
|
|
2
|
+
import { creditsActive, tokensOnlyActive, formatCreditsLine, CREDITS_PENDING } from '../cost/credits.js';
|
|
3
|
+
import { AZURE_INVOICE_LINE } from '../cost/display-mode.js';
|
|
3
4
|
import { formatTurnReceipt, turnSpendText } from './footer-line.js';
|
|
4
5
|
import { formatAttention } from './notice-shape.js';
|
|
5
6
|
import { paint } from './theme.js';
|
|
@@ -79,12 +80,18 @@ export function formatStatusFooter(params) {
|
|
|
79
80
|
const tokStr = inCredits ? '' : tokens.toLocaleString() + ' tok this turn';
|
|
80
81
|
const cachedStr = !inCredits && cachedTokens > 0 ? `${cachedTokens.toLocaleString()} cached` : '';
|
|
81
82
|
const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const costSessStr = inCredits
|
|
83
|
+
// Azure (Task 5b): tokens only, the price line says where the bill is.
|
|
84
|
+
const tokensOnly = tokensOnlyActive();
|
|
85
|
+
const costTurnStr = tokensOnly
|
|
86
86
|
? ''
|
|
87
|
-
:
|
|
87
|
+
: inCredits
|
|
88
|
+
? (credits ? formatCreditsLine(credits) : CREDITS_PENDING)
|
|
89
|
+
: (costThisTurn != null ? formatCost(costThisTurn) : '');
|
|
90
|
+
const costSessStr = tokensOnly
|
|
91
|
+
? AZURE_INVOICE_LINE
|
|
92
|
+
: inCredits
|
|
93
|
+
? ''
|
|
94
|
+
: (costSession != null ? `session: ${formatCost(costSession)}` : '');
|
|
88
95
|
// Classic full receipt (piece 2 restyle — same facts, reviewed pin):
|
|
89
96
|
// rules at uiWidth() in the frame role, ' · ' separators, the large-session
|
|
90
97
|
// advisory as a `!` notice (largeSessionNotice).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure tenant mode, Task 6b fix round 1 — a yes/no question at REPL start
|
|
3
|
+
* (the endpoint confirmation, "Sign in to Microsoft now?"). Ctrl+C there makes
|
|
4
|
+
* inquirer reject with ExitPromptError; that is the person's "no", never an
|
|
5
|
+
* unhandled error that ends the CLI with a stack. Any other error still throws.
|
|
6
|
+
*/
|
|
7
|
+
export function askOrNo(ask) {
|
|
8
|
+
return async (q) => {
|
|
9
|
+
try {
|
|
10
|
+
return await ask(q);
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
if (err?.name === 'ExitPromptError')
|
|
14
|
+
return false;
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|