@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
|
@@ -33,7 +33,9 @@ import Anthropic from '@anthropic-ai/sdk';
|
|
|
33
33
|
import keychain from '../auth/keychain.js';
|
|
34
34
|
import { byokRouterModel } from '../models/server-config.js';
|
|
35
35
|
import { addRouterSpend } from '../cost/router-spend.js';
|
|
36
|
+
import { reportCallUsage } from '../usage/usage-reporter.js';
|
|
36
37
|
import { loadRouterPrompt } from './router-prompt.js';
|
|
38
|
+
import { azureSdkOptions } from '../connection/azure-auth.js';
|
|
37
39
|
import { ROUTER_UNPARSEABLE_RESULT, buildRouterRequest, runRouterCalls, } from './router-core.js';
|
|
38
40
|
/** Same keychain entry the BYOK provider reads (byok-provider.ts) and login writes. */
|
|
39
41
|
export const BYOK_KEYCHAIN_SERVICE = 'cspeach.anthropic';
|
|
@@ -65,6 +67,17 @@ export class ByokRouterFailure extends Error {
|
|
|
65
67
|
this.name = 'ByokRouterFailure';
|
|
66
68
|
}
|
|
67
69
|
}
|
|
70
|
+
/** Final fix wave B — fire-and-forget, never throws: usage counts are best effort. */
|
|
71
|
+
function reportAzureRouterCall(family, u) {
|
|
72
|
+
try {
|
|
73
|
+
void reportCallUsage({
|
|
74
|
+
mode: 'azure', skill: '_router', model: family,
|
|
75
|
+
input: u.input, output: u.output, cacheRead: u.cacheRead, cacheWrite: u.cacheCreate,
|
|
76
|
+
sessionId: '', started: true,
|
|
77
|
+
}).catch(() => { });
|
|
78
|
+
}
|
|
79
|
+
catch { /* best effort */ }
|
|
80
|
+
}
|
|
68
81
|
function causeOf(err) {
|
|
69
82
|
const e = err;
|
|
70
83
|
if (e?.cause)
|
|
@@ -86,10 +99,13 @@ export async function classifyByok(prompt, context, images, opts = {}) {
|
|
|
86
99
|
const target = opts.target ?? { mode: 'byok' };
|
|
87
100
|
if (opts.signal?.aborted)
|
|
88
101
|
throw cancelled();
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
102
|
+
// Azure signs in through target.auth() (Entra token or Azure key), never the keychain entries here.
|
|
103
|
+
const key = target.mode === 'azure'
|
|
104
|
+
? null
|
|
105
|
+
: target.mode === 'byok'
|
|
106
|
+
? await keychain.getPassword(BYOK_KEYCHAIN_SERVICE, BYOK_KEYCHAIN_ACCOUNT)
|
|
107
|
+
: await keychain.getPassword(AI_HUB_KEYCHAIN_SERVICE, AI_HUB_KEYCHAIN_ACCOUNT);
|
|
108
|
+
if (target.mode !== 'azure' && !key) {
|
|
93
109
|
throw new Error(target.mode === 'byok'
|
|
94
110
|
? 'BYOK Anthropic API key not configured — run: cspeach config set llm.byok_key <key>'
|
|
95
111
|
: 'AI Hub token not configured — run: cspeach config set llm.ai_hub_token <token>');
|
|
@@ -104,18 +120,23 @@ export async function classifyByok(prompt, context, images, opts = {}) {
|
|
|
104
120
|
const userCancelled = () => opts.signal?.aborted === true;
|
|
105
121
|
try {
|
|
106
122
|
const { prompt: routerPrompt } = await loadRouterPrompt({ signal: ctrl.signal });
|
|
107
|
-
const model = target.mode === 'byok' ? byokRouterModel() : (target.modelAlias ?? AI_HUB_DEFAULT_MODEL);
|
|
123
|
+
const model = target.mode === 'byok' ? byokRouterModel() : target.mode === 'azure' ? target.model : (target.modelAlias ?? AI_HUB_DEFAULT_MODEL);
|
|
108
124
|
const full = buildRouterRequest(model, prompt, context, images, routerPrompt);
|
|
109
125
|
// ai-hub: today's field set only (the hub has no flag for newer fields).
|
|
110
|
-
|
|
126
|
+
// azure: output_config only when the profile allows structured outputs.
|
|
127
|
+
const request = target.mode === 'byok' || (target.mode === 'azure' && target.structuredOutputs)
|
|
111
128
|
? full
|
|
112
129
|
: (({ output_config: _dropped, ...rest }) => rest)(full);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
timeout: ROUTER_CALL_TIMEOUT_MS
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
// Azure: the guarded options (host allow-list on every request URL, no
|
|
131
|
+
// redirects, the other auth field null so no ANTHROPIC_* env var leaks).
|
|
132
|
+
const sdk = target.mode === 'azure'
|
|
133
|
+
? new Anthropic({ ...azureSdkOptions(target.baseURL, await target.auth()), maxRetries: 1, timeout: ROUTER_CALL_TIMEOUT_MS })
|
|
134
|
+
: new Anthropic({
|
|
135
|
+
apiKey: key,
|
|
136
|
+
maxRetries: 1,
|
|
137
|
+
timeout: ROUTER_CALL_TIMEOUT_MS,
|
|
138
|
+
...(target.mode === 'ai-hub' ? { baseURL: target.baseURL } : {}),
|
|
139
|
+
});
|
|
119
140
|
const { result } = await runRouterCalls((req) => sdk.messages.create(req, { signal: ctrl.signal }), request, {
|
|
120
141
|
// Once per session — a model that rejects structured outputs would
|
|
121
142
|
// otherwise print this on every prompt.
|
|
@@ -129,8 +150,14 @@ export async function classifyByok(prompt, context, images, opts = {}) {
|
|
|
129
150
|
// call finishes, so a deadline / cancel after call 1 keeps call 1
|
|
130
151
|
// (review S1). ai-hub is billed by their BTP account at rates we
|
|
131
152
|
// don't know: not priced here.
|
|
132
|
-
|
|
133
|
-
|
|
153
|
+
// Azure (final fix wave B): each router call is counted like any other
|
|
154
|
+
// azure call — a usage record and the local limit count.
|
|
155
|
+
onCall: (c) => {
|
|
156
|
+
if (target.mode === 'byok')
|
|
157
|
+
addRouterSpend(model, c.usage);
|
|
158
|
+
else if (target.mode === 'azure')
|
|
159
|
+
reportAzureRouterCall(target.family, c.usage);
|
|
160
|
+
},
|
|
134
161
|
});
|
|
135
162
|
return result ?? { ...ROUTER_UNPARSEABLE_RESULT, alternates: [] };
|
|
136
163
|
}
|
|
@@ -6,6 +6,7 @@ import { isBuiltinCommandHead } from '../repl/builtin-commands.js';
|
|
|
6
6
|
import { expandImageAttachments } from '../projects/image-attachments.js';
|
|
7
7
|
import { expandImageChips } from '../ui/clipboard-image.js';
|
|
8
8
|
import { classifyByok, ByokRouterFailure } from './byok-classifier.js';
|
|
9
|
+
import { routerRouteFor } from './router-target.js';
|
|
9
10
|
/** A non-2xx answer from the classify endpoint (fix 9: carries the kind). */
|
|
10
11
|
export class ClassifyHttpError extends Error {
|
|
11
12
|
status;
|
|
@@ -165,26 +166,14 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
165
166
|
// with the customer's own key; ai-hub through the customer's own hub;
|
|
166
167
|
// local by the bundled rules (byok-classifier.ts / localModeResult).
|
|
167
168
|
// Managed keeps /v1/classify below, byte-identical.
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
169
|
+
const route = routerRouteFor(cfg);
|
|
170
|
+
if (route.kind === 'local-rules') {
|
|
170
171
|
if (opts.signal?.aborted)
|
|
171
172
|
throw routingCancelled();
|
|
172
173
|
return localModeResult(prompt);
|
|
173
174
|
}
|
|
174
|
-
if (
|
|
175
|
-
return classifyPromptByok(prompt, context, opts,
|
|
176
|
-
if (mode === 'ai-hub') {
|
|
177
|
-
const baseURL = cfg.llm.ai_hub_base_url;
|
|
178
|
-
if (!baseURL)
|
|
179
|
-
throw new Error('ai-hub mode requires llm.ai_hub_base_url — run: cspeach config set llm.ai_hub_base_url <url>');
|
|
180
|
-
// llm.ai_hub_router_model (optional, 2026-10-02) routes on a cheaper /
|
|
181
|
-
// faster deployment; unset ⇒ the session alias.
|
|
182
|
-
return classifyPromptByok(prompt, context, opts, {
|
|
183
|
-
mode: 'ai-hub',
|
|
184
|
-
baseURL,
|
|
185
|
-
modelAlias: cfg.llm.ai_hub_router_model ?? cfg.llm.ai_hub_model_alias,
|
|
186
|
-
});
|
|
187
|
-
}
|
|
175
|
+
if (route.kind === 'direct')
|
|
176
|
+
return classifyPromptByok(prompt, context, opts, route.target);
|
|
188
177
|
const store = await getStore();
|
|
189
178
|
const key = await store.get();
|
|
190
179
|
if (!key)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { profileForMode } from '../agent/connection-profile.js';
|
|
2
|
+
import { getActiveConnection } from '../connection/cache.js';
|
|
3
|
+
import { azureTurnVerdict, AzureConnectionRefusedError } from '../connection/state.js';
|
|
4
|
+
import { selectAzureAuth } from '../connection/azure-auth.js';
|
|
5
|
+
import { routerDeployment } from '../models/model-identity.js';
|
|
6
|
+
export function routerRouteFor(cfg, profileFor = profileForMode) {
|
|
7
|
+
const mode = cfg.llm?.mode;
|
|
8
|
+
const p = profileFor(mode);
|
|
9
|
+
if (p.router === 'local-rules')
|
|
10
|
+
return { kind: 'local-rules' };
|
|
11
|
+
if (p.router === 'proxy')
|
|
12
|
+
return { kind: 'proxy' };
|
|
13
|
+
switch (mode) {
|
|
14
|
+
case 'byok':
|
|
15
|
+
return { kind: 'direct', target: { mode: 'byok' } };
|
|
16
|
+
case 'ai-hub': {
|
|
17
|
+
const baseURL = cfg.llm.ai_hub_base_url;
|
|
18
|
+
if (!baseURL)
|
|
19
|
+
throw new Error('ai-hub mode requires llm.ai_hub_base_url — run: cspeach config set llm.ai_hub_base_url <url>');
|
|
20
|
+
// llm.ai_hub_router_model (optional, 2026-10-02) routes on a cheaper /
|
|
21
|
+
// faster deployment; unset ⇒ the session alias.
|
|
22
|
+
return { kind: 'direct', target: { mode: 'ai-hub', baseURL, modelAlias: cfg.llm.ai_hub_router_model ?? cfg.llm.ai_hub_model_alias } };
|
|
23
|
+
}
|
|
24
|
+
case 'azure': {
|
|
25
|
+
const cached = getActiveConnection();
|
|
26
|
+
const v = azureTurnVerdict(cached);
|
|
27
|
+
if (!v.allow)
|
|
28
|
+
throw new AzureConnectionRefusedError(v.message);
|
|
29
|
+
const c = cached.connection;
|
|
30
|
+
return {
|
|
31
|
+
kind: 'direct',
|
|
32
|
+
target: {
|
|
33
|
+
mode: 'azure', baseURL: c.endpoint, model: routerDeployment(c.deployments).name, family: routerDeployment(c.deployments).model,
|
|
34
|
+
auth: async () => (await selectAzureAuth(c)).getAuth(), structuredOutputs: p.structuredOutputs,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
default:
|
|
39
|
+
throw new Error(`no direct router for llm.mode ${String(mode)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/sap/onboarding.js
CHANGED
|
@@ -126,73 +126,79 @@ export async function runAddSapWizard() {
|
|
|
126
126
|
default: 'approval-gated',
|
|
127
127
|
});
|
|
128
128
|
cfg.write_mode = write_mode;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
{
|
|
133
|
-
name: 'managed (recommended) — we handle the Anthropic account',
|
|
134
|
-
value: 'managed',
|
|
135
|
-
description: 'Your prompts route through api.cspeach.dev. We bill, we ratelimit, we sign skills. Simplest setup.',
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
name: 'byok — your own Anthropic account',
|
|
139
|
-
value: 'byok',
|
|
140
|
-
description: 'Your prompts go direct to Anthropic with your API key. You own the contract. Skills signed + fetched from our public manifest.',
|
|
141
|
-
},
|
|
142
|
-
{
|
|
143
|
-
name: 'ai-hub — your SAP AI Core tenant (endorsed architecture)',
|
|
144
|
-
value: 'ai-hub',
|
|
145
|
-
description: 'Your prompts route through your SAP BTP AI Core proxy. Data stays in your SAP landscape. Strongest compliance posture (short of SAP Partner Edge).',
|
|
146
|
-
},
|
|
147
|
-
{
|
|
148
|
-
name: 'local — your own Ollama / vLLM endpoint (air-gap)',
|
|
149
|
-
value: 'local',
|
|
150
|
-
description: 'No external AI. Skills bundled with the CLI binary. Tool-calling quality depends on the local model.',
|
|
151
|
-
},
|
|
152
|
-
],
|
|
153
|
-
default: 'managed',
|
|
154
|
-
});
|
|
155
|
-
cfg.llm = { mode: llm_mode };
|
|
156
|
-
if (llm_mode === 'ai-hub') {
|
|
157
|
-
cfg.llm.ai_hub_base_url = await input({
|
|
158
|
-
message: 'AI Core proxy URL (e.g. https://proxy.yourcorp.com/v1):',
|
|
159
|
-
validate: v => v.startsWith('https://') || v.startsWith('http://') ? true : 'Must be a URL',
|
|
160
|
-
});
|
|
161
|
-
cfg.llm.ai_hub_model_alias = await input({
|
|
162
|
-
message: 'AI Core model alias (default: anthropic--claude-opus-4-6):',
|
|
163
|
-
default: 'anthropic--claude-opus-4-6',
|
|
164
|
-
});
|
|
165
|
-
const aiHubToken = await password({
|
|
166
|
-
message: 'AI Core API token (input masked):',
|
|
167
|
-
mask: '*',
|
|
168
|
-
});
|
|
169
|
-
try {
|
|
170
|
-
await keychain.setPassword('cspeach.ai-hub', 'token', aiHubToken);
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
console.log(chalk.yellow('Warning: keychain unavailable; AI Hub token will be prompted per session.'));
|
|
174
|
-
}
|
|
129
|
+
if (cfg.llm?.mode === 'azure') {
|
|
130
|
+
// Azure tenant mode — the company chose the models; the wizard never changes it.
|
|
131
|
+
console.log(chalk.dim("AI models: your company's Azure (set by your company)."));
|
|
175
132
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
message: '
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
133
|
+
else {
|
|
134
|
+
const llm_mode = await select({
|
|
135
|
+
message: 'How should CSPeach reach the AI model?',
|
|
136
|
+
choices: [
|
|
137
|
+
{
|
|
138
|
+
name: 'managed (recommended) — we handle the Anthropic account',
|
|
139
|
+
value: 'managed',
|
|
140
|
+
description: 'Your prompts route through api.cspeach.dev. We bill, we ratelimit, we sign skills. Simplest setup.',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'byok — your own Anthropic account',
|
|
144
|
+
value: 'byok',
|
|
145
|
+
description: 'Your prompts go direct to Anthropic with your API key. You own the contract. Skills signed + fetched from our public manifest.',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'ai-hub — your SAP AI Core tenant (endorsed architecture)',
|
|
149
|
+
value: 'ai-hub',
|
|
150
|
+
description: 'Your prompts route through your SAP BTP AI Core proxy. Data stays in your SAP landscape. Strongest compliance posture (short of SAP Partner Edge).',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'local — your own Ollama / vLLM endpoint (air-gap)',
|
|
154
|
+
value: 'local',
|
|
155
|
+
description: 'No external AI. Skills bundled with the CLI binary. Tool-calling quality depends on the local model.',
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
default: 'managed',
|
|
184
159
|
});
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
160
|
+
cfg.llm = { mode: llm_mode };
|
|
161
|
+
if (llm_mode === 'ai-hub') {
|
|
162
|
+
cfg.llm.ai_hub_base_url = await input({
|
|
163
|
+
message: 'AI Core proxy URL (e.g. https://proxy.yourcorp.com/v1):',
|
|
164
|
+
validate: v => v.startsWith('https://') || v.startsWith('http://') ? true : 'Must be a URL',
|
|
165
|
+
});
|
|
166
|
+
cfg.llm.ai_hub_model_alias = await input({
|
|
167
|
+
message: 'AI Core model alias (default: anthropic--claude-opus-4-6):',
|
|
168
|
+
default: 'anthropic--claude-opus-4-6',
|
|
169
|
+
});
|
|
170
|
+
const aiHubToken = await password({
|
|
171
|
+
message: 'AI Core API token (input masked):',
|
|
172
|
+
mask: '*',
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
await keychain.setPassword('cspeach.ai-hub', 'token', aiHubToken);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
console.log(chalk.yellow('Warning: keychain unavailable; AI Hub token will be prompted per session.'));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (llm_mode === 'local') {
|
|
182
|
+
cfg.llm.local_base_url = await input({
|
|
183
|
+
message: 'Local endpoint base URL (default: http://localhost:11434/v1 for Ollama):',
|
|
184
|
+
default: 'http://localhost:11434/v1',
|
|
185
|
+
});
|
|
186
|
+
cfg.llm.local_model = await input({
|
|
187
|
+
message: 'Model name (e.g. llama3.1:70b):',
|
|
188
|
+
default: 'llama3.1:70b',
|
|
189
|
+
});
|
|
193
190
|
}
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
if (llm_mode === 'byok') {
|
|
192
|
+
const byokKey = await password({
|
|
193
|
+
message: 'Anthropic API key sk-ant-... (input masked):',
|
|
194
|
+
mask: '*',
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
await keychain.setPassword('cspeach.anthropic', 'api-key', byokKey);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
console.log(chalk.yellow('Warning: keychain unavailable; Anthropic key will be prompted per session.'));
|
|
201
|
+
}
|
|
196
202
|
}
|
|
197
203
|
}
|
|
198
204
|
}
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
// never an invented approval.
|
|
32
32
|
import { summarise } from '../cost/cost-log.js';
|
|
33
33
|
import { CLI_VERSION } from '../lib/version.js';
|
|
34
|
-
import { creditsActive, formatCredits } from '../cost/credits.js';
|
|
34
|
+
import { creditsActive, tokensOnlyActive, formatCredits } from '../cost/credits.js';
|
|
35
|
+
import { AZURE_INVOICE_LINE } from '../cost/display-mode.js';
|
|
35
36
|
/**
|
|
36
37
|
* Object-mutating tools — one row per completed call in the Writes table.
|
|
37
38
|
* Kept as a literal name set (not the tool registry) so this pure builder
|
|
@@ -433,7 +434,13 @@ export function buildAuditReport(input) {
|
|
|
433
434
|
const auditEntries = costEntries.filter((e) => e.label);
|
|
434
435
|
const auditCost = Math.round(auditEntries.reduce((s, e) => s + e.cost, 0) * 10000) / 10000;
|
|
435
436
|
const inCredits = creditsActive();
|
|
436
|
-
|
|
437
|
+
// Azure (Task 5b): tokens only — no price of ours for the customer's deployments.
|
|
438
|
+
const tokensOnly = tokensOnlyActive();
|
|
439
|
+
const noPrice = inCredits || tokensOnly;
|
|
440
|
+
if (tokensOnly) {
|
|
441
|
+
L(`- **Total:** ${AZURE_INVOICE_LINE} · ${cost.turns} turn${cost.turns === 1 ? '' : 's'}`);
|
|
442
|
+
}
|
|
443
|
+
else if (inCredits) {
|
|
437
444
|
const unit = credits?.unitLabel ?? 'credits';
|
|
438
445
|
const logged = costEntries.reduce((s, e) => s + (typeof e.credits_charged === 'number' ? e.credits_charged : 0), 0);
|
|
439
446
|
const total = credits && credits.sessionTotal > 0 ? credits.sessionTotal : logged;
|
|
@@ -449,14 +456,14 @@ export function buildAuditReport(input) {
|
|
|
449
456
|
if (auditEntries.length > 0) {
|
|
450
457
|
// Credits mode: the labelled-audit turn COUNT is still useful provenance;
|
|
451
458
|
// its USD cost is not ours to publish to a customer.
|
|
452
|
-
L(
|
|
459
|
+
L(noPrice
|
|
453
460
|
? `- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}`
|
|
454
461
|
: `- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}: $${auditCost.toFixed(4)}`);
|
|
455
462
|
}
|
|
456
463
|
if (cost.byModel.length > 1) {
|
|
457
464
|
L('- **By model:**');
|
|
458
465
|
for (const m of cost.byModel) {
|
|
459
|
-
L(
|
|
466
|
+
L(noPrice
|
|
460
467
|
? ` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'}`
|
|
461
468
|
: ` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'} · $${m.cost.toFixed(4)}`);
|
|
462
469
|
}
|
|
@@ -6,6 +6,31 @@ import { MANIFEST_PUBLIC_KEYS } from './signing-public-key.js';
|
|
|
6
6
|
import { canonicalise } from './canonical.js';
|
|
7
7
|
// @noble/ed25519 v2 requires an ed25519 sha512 hook.
|
|
8
8
|
ed.etc.sha512Sync = (...m) => sha512(concatBytes(...m));
|
|
9
|
+
/**
|
|
10
|
+
* A body that does not match its signed manifest entry. Fail closed: never
|
|
11
|
+
* loaded, never cached, and deliberately NOT a "skills unavailable" error, so
|
|
12
|
+
* it can never open the public fallback or the disk cache. Hashes stay out of
|
|
13
|
+
* the user message (they go to `debug`, written only to the debug log).
|
|
14
|
+
*/
|
|
15
|
+
export class SkillIntegrityError extends Error {
|
|
16
|
+
debug;
|
|
17
|
+
constructor(debug) {
|
|
18
|
+
super('This skill failed its integrity check and was not loaded. Update CSPeach or contact support.');
|
|
19
|
+
this.name = 'SkillIntegrityError';
|
|
20
|
+
this.debug = debug;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The manifest could not be fetched: a network failure or a 5xx (incl. 503
|
|
25
|
+
* "not yet published"). Task 8 — the ONLY manifest failure the on-disk skills
|
|
26
|
+
* cache may cover. A bad signature, a 4xx or bad JSON never is.
|
|
27
|
+
*/
|
|
28
|
+
export class ManifestUnavailableError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'ManifestUnavailableError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
9
34
|
export class ManifestClient {
|
|
10
35
|
manifestUrl;
|
|
11
36
|
constructor(manifestUrl) {
|
|
@@ -17,21 +42,29 @@ export class ManifestClient {
|
|
|
17
42
|
r = await fetch(this.manifestUrl, { signal: AbortSignal.timeout(10_000) });
|
|
18
43
|
}
|
|
19
44
|
catch (e) {
|
|
20
|
-
throw new
|
|
45
|
+
throw new ManifestUnavailableError(`manifest fetch failed (${this.manifestUrl}): ${e.message}`);
|
|
21
46
|
}
|
|
22
47
|
if (r.status === 503) {
|
|
23
48
|
const body = await r.json().catch(() => ({}));
|
|
24
49
|
const raw = String(body.error ?? 'server says not ready');
|
|
25
50
|
// Cap length — an untrusted server could flood the error message.
|
|
26
51
|
const safe = raw.length > 200 ? raw.slice(0, 200) + '…' : raw;
|
|
27
|
-
throw new
|
|
52
|
+
throw new ManifestUnavailableError(`manifest not yet published (HTTP 503): ${safe}`);
|
|
28
53
|
}
|
|
54
|
+
if (r.status >= 500)
|
|
55
|
+
throw new ManifestUnavailableError(`manifest fetch: HTTP ${r.status} from ${this.manifestUrl}`);
|
|
29
56
|
if (!r.ok)
|
|
30
57
|
throw new Error(`manifest fetch: HTTP ${r.status} from ${this.manifestUrl}`);
|
|
31
58
|
return await r.json();
|
|
32
59
|
}
|
|
33
60
|
async fetchAndVerifyManifest() {
|
|
34
|
-
|
|
61
|
+
return this.verifyManifest(await this.fetchManifest());
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Verify a manifest's ed25519 signature — fetched just now or read from the
|
|
65
|
+
* on-disk skills cache (Task 8). Works offline.
|
|
66
|
+
*/
|
|
67
|
+
async verifyManifest(m) {
|
|
35
68
|
const { signature, ...payload } = m;
|
|
36
69
|
const canonical = canonicalise(payload);
|
|
37
70
|
const canonicalBytes = new TextEncoder().encode(canonical);
|
|
@@ -82,7 +115,7 @@ export class ManifestClient {
|
|
|
82
115
|
// the manifest-anchored expected value, constitutes an integrity check.
|
|
83
116
|
const actualSha = bytesToHex(sha256(new TextEncoder().encode(j.body)));
|
|
84
117
|
if (opts.expectedSha256 !== actualSha) {
|
|
85
|
-
throw new
|
|
118
|
+
throw new SkillIntegrityError(`skill ${name}: sha256 mismatch with manifest (manifest=${opts.expectedSha256}, computed=${actualSha}) — body may have been tampered in transit`);
|
|
86
119
|
}
|
|
87
120
|
return { name, body: j.body, sha256: actualSha, signature: '', signedAt: j.updated_at };
|
|
88
121
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security batch item 2 — <CSPeach home>/skills-cache/ for byok, ai-hub and
|
|
3
|
+
* local. manifest.json (re-verified with its ed25519 signature on every load)
|
|
4
|
+
* plus one <skill>.json per body. A body loads only when its sha256 AND
|
|
5
|
+
* updated_at match the signed manifest entry; anything else is refetched.
|
|
6
|
+
* Offline use works from here within the licence check's 72-hour grace.
|
|
7
|
+
*
|
|
8
|
+
* The cache is never a trust anchor: a stored hash is never read back (the
|
|
9
|
+
* body is re-hashed every load), a corrupt or edited file loads nothing, and
|
|
10
|
+
* a name that is not a plain skill name never becomes a path.
|
|
11
|
+
*
|
|
12
|
+
* No TTL by design: the licence turn-gate (72-hour grace) bounds offline use.
|
|
13
|
+
*/
|
|
14
|
+
import { promises as fs } from 'node:fs';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
18
|
+
import { bytesToHex } from '@noble/hashes/utils';
|
|
19
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
20
|
+
const SAFE_NAME = /^[a-z0-9_-]{1,64}$/;
|
|
21
|
+
const MANIFEST_FILE = 'manifest.json';
|
|
22
|
+
/** A *.tmp older than this is a crash leftover (a live write renames within ms). */
|
|
23
|
+
const STALE_TMP_MS = 60_000;
|
|
24
|
+
/** A plain skill name, and never the manifest's own file name. */
|
|
25
|
+
function safeName(name) {
|
|
26
|
+
return typeof name === 'string' && SAFE_NAME.test(name) && `${name}.json` !== MANIFEST_FILE;
|
|
27
|
+
}
|
|
28
|
+
export function skillsCacheDir() {
|
|
29
|
+
return path.join(cspeachRoot(), 'skills-cache');
|
|
30
|
+
}
|
|
31
|
+
const manifestFile = () => path.join(skillsCacheDir(), MANIFEST_FILE);
|
|
32
|
+
const bodyFile = (name) => path.join(skillsCacheDir(), `${name}.json`);
|
|
33
|
+
async function writeAtomic(p, text) {
|
|
34
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
35
|
+
// Unique per write (as key-resolver writeMeta): two writes in one process
|
|
36
|
+
// must never share a temp file.
|
|
37
|
+
const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
38
|
+
try {
|
|
39
|
+
await fs.writeFile(tmp, text, { encoding: 'utf-8', mode: 0o600 });
|
|
40
|
+
await fs.rename(tmp, p);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export const fileSkillsCache = {
|
|
47
|
+
async saveManifest(m) {
|
|
48
|
+
await writeAtomic(manifestFile(), JSON.stringify(m)).catch(() => { });
|
|
49
|
+
},
|
|
50
|
+
async loadManifest(verify) {
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(await fs.readFile(manifestFile(), 'utf-8'));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.skills))
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
return await verify(parsed);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async loadBody(entry) {
|
|
68
|
+
if (!safeName(entry.name))
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
const c = JSON.parse(await fs.readFile(bodyFile(entry.name), 'utf-8'));
|
|
72
|
+
if (c.name !== entry.name || typeof c.body !== 'string' || c.updated_at !== entry.updated_at)
|
|
73
|
+
return null;
|
|
74
|
+
const actual = bytesToHex(sha256(new TextEncoder().encode(c.body)));
|
|
75
|
+
if (actual !== entry.sha256)
|
|
76
|
+
return null;
|
|
77
|
+
return { name: entry.name, body: c.body, sha256: actual, signature: '', signedAt: typeof c.signedAt === 'string' ? c.signedAt : '' };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
async saveBody(entry, body) {
|
|
84
|
+
if (!safeName(entry.name))
|
|
85
|
+
return;
|
|
86
|
+
await writeAtomic(bodyFile(entry.name), JSON.stringify({ name: entry.name, body: body.body, updated_at: entry.updated_at, signedAt: body.signedAt })).catch(() => { });
|
|
87
|
+
},
|
|
88
|
+
async removeBody(name) {
|
|
89
|
+
if (!safeName(name))
|
|
90
|
+
return;
|
|
91
|
+
await fs.rm(bodyFile(name), { force: true }).catch(() => { });
|
|
92
|
+
},
|
|
93
|
+
async prune(m) {
|
|
94
|
+
const keep = new Set([MANIFEST_FILE, ...m.skills.filter((s) => safeName(s.name)).map((s) => `${s.name}.json`)]);
|
|
95
|
+
let removed = 0;
|
|
96
|
+
try {
|
|
97
|
+
for (const f of await fs.readdir(skillsCacheDir())) {
|
|
98
|
+
const p = path.join(skillsCacheDir(), f);
|
|
99
|
+
if (f.endsWith('.json') && !keep.has(f)) {
|
|
100
|
+
await fs.rm(p, { force: true });
|
|
101
|
+
removed++;
|
|
102
|
+
}
|
|
103
|
+
else if (f.endsWith('.tmp')) {
|
|
104
|
+
const st = await fs.stat(p).catch(() => null);
|
|
105
|
+
if (st && Date.now() - st.mtimeMs > STALE_TMP_MS) {
|
|
106
|
+
await fs.rm(p, { force: true });
|
|
107
|
+
removed++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch { /* no cache yet */ }
|
|
113
|
+
return removed;
|
|
114
|
+
},
|
|
115
|
+
};
|