@robota-sdk/agent-command 3.0.0-beta.67 → 3.0.0-beta.69
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/README.md +0 -1
- package/dist/node/index.cjs +57 -28
- package/dist/node/index.d.ts +3 -16
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +57 -28
- package/dist/node/index.js.map +1 -1
- package/package.json +3 -3
- package/src/compact/__tests__/compact-command-module.test.ts +13 -4
- package/src/compact/compact-command-module.ts +2 -0
- package/src/compact/compact-command.ts +11 -4
- package/src/context/__tests__/context-command-module.test.ts +42 -0
- package/src/default/default-command-modules.ts +0 -5
- package/src/help/__tests__/help-command.test.ts +119 -0
- package/src/index.ts +0 -1
- package/src/provider/__tests__/org-policy.test.ts +165 -0
- package/src/provider/__tests__/provider-command-module.test.ts +18 -29
- package/src/provider/__tests__/provider-setup-flow.test.ts +2 -2
- package/src/provider/provider-command-execution.ts +2 -2
- package/src/provider/provider-command-module.ts +4 -2
- package/src/provider/provider-command-profile-operations.ts +37 -49
- package/src/provider/provider-onboarding.ts +64 -0
- package/src/provider/provider-setup-flow.ts +11 -2
- package/src/provider/provider-startup.ts +15 -3
- package/src/session/__tests__/model-pricing.test.ts +64 -0
- package/src/session/__tests__/session-command-module.test.ts +51 -7
- package/src/session/model-pricing.ts +79 -0
- package/src/session/session-command.ts +124 -8
- package/src/model/__tests__/model-command-module.test.ts +0 -273
- package/src/model/index.ts +0 -6
- package/src/model/model-command-module.ts +0 -70
- package/src/model/model-command.ts +0 -41
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildProviderSetupPatch,
|
|
3
|
+
formatOrgPolicyViolationMessage,
|
|
4
|
+
isApiKeyPlaintext,
|
|
3
5
|
setCurrentProvider,
|
|
4
6
|
upsertProviderProfile,
|
|
5
7
|
} from '@robota-sdk/agent-framework';
|
|
@@ -19,12 +21,6 @@ import type {
|
|
|
19
21
|
IProviderSetupInput,
|
|
20
22
|
} from '@robota-sdk/agent-framework';
|
|
21
23
|
|
|
22
|
-
const YES = 'yes';
|
|
23
|
-
const PROVIDER_RESTART_EFFECT = {
|
|
24
|
-
type: 'session-restart-requested',
|
|
25
|
-
reason: 'other',
|
|
26
|
-
} as const;
|
|
27
|
-
|
|
28
24
|
export function formatProviderChoiceLabel(
|
|
29
25
|
name: string,
|
|
30
26
|
profile: IProviderProfileSettings,
|
|
@@ -40,53 +36,37 @@ export function buildProviderSwitch(
|
|
|
40
36
|
options: IProviderCommandModuleOptions,
|
|
41
37
|
): ICommandResult {
|
|
42
38
|
if (!profileName) {
|
|
43
|
-
return { message: 'Usage: provider
|
|
39
|
+
return { message: 'Usage: /provider switch <profile>', success: false };
|
|
44
40
|
}
|
|
45
41
|
if (!providers?.[profileName]) {
|
|
46
42
|
return { message: `Provider profile "${profileName}" was not found.`, success: false };
|
|
47
43
|
}
|
|
44
|
+
const { orgPolicy } = options;
|
|
45
|
+
if (orgPolicy?.allowedProviders && !orgPolicy.allowedProviders.includes(profileName)) {
|
|
46
|
+
return {
|
|
47
|
+
message: formatOrgPolicyViolationMessage(
|
|
48
|
+
`Provider "${profileName}" is not allowed by your organization policy. Allowed: ${orgPolicy.allowedProviders.join(', ')}.`,
|
|
49
|
+
orgPolicy.adminContact,
|
|
50
|
+
),
|
|
51
|
+
success: false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
48
54
|
if (options.settings.readMergedSettings().currentProvider === profileName) {
|
|
49
|
-
return { message: `
|
|
55
|
+
return { message: `Already using provider "${profileName}".`, success: true };
|
|
50
56
|
}
|
|
57
|
+
const profile = providers[profileName];
|
|
58
|
+
const target = options.settings.readTargetSettings();
|
|
59
|
+
const merged = options.settings.readMergedSettings();
|
|
60
|
+
const next =
|
|
61
|
+
target.providers?.[profileName] !== undefined || merged.providers?.[profileName] !== undefined
|
|
62
|
+
? { ...target, currentProvider: profileName }
|
|
63
|
+
: setCurrentProvider(target, profileName);
|
|
64
|
+
options.settings.writeTargetSettings(next);
|
|
65
|
+
const modelLabel = profile.model ?? 'unknown model';
|
|
51
66
|
return {
|
|
52
|
-
message: `
|
|
67
|
+
message: `Switched to ${profileName} (${modelLabel}). History preserved.`,
|
|
53
68
|
success: true,
|
|
54
|
-
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function createProviderSwitchInteraction(
|
|
59
|
-
profileName: string,
|
|
60
|
-
options: IProviderCommandModuleOptions,
|
|
61
|
-
): ICommandInteraction {
|
|
62
|
-
return {
|
|
63
|
-
prompt: {
|
|
64
|
-
kind: 'choice',
|
|
65
|
-
title: `Change provider to ${profileName}? This will restart the session.`,
|
|
66
|
-
options: [
|
|
67
|
-
{ value: YES, label: 'Yes' },
|
|
68
|
-
{ value: 'no', label: 'No' },
|
|
69
|
-
],
|
|
70
|
-
},
|
|
71
|
-
submit: (value) => {
|
|
72
|
-
if (value !== YES) {
|
|
73
|
-
return { message: 'Provider change cancelled.', success: true };
|
|
74
|
-
}
|
|
75
|
-
const merged = options.settings.readMergedSettings();
|
|
76
|
-
const target = options.settings.readTargetSettings();
|
|
77
|
-
const next =
|
|
78
|
-
target.providers?.[profileName] !== undefined ||
|
|
79
|
-
merged.providers?.[profileName] !== undefined
|
|
80
|
-
? { ...target, currentProvider: profileName }
|
|
81
|
-
: setCurrentProvider(target, profileName);
|
|
82
|
-
options.settings.writeTargetSettings(next);
|
|
83
|
-
return {
|
|
84
|
-
message: `Provider changed to ${profileName}. Restarting...`,
|
|
85
|
-
success: true,
|
|
86
|
-
effects: [{ ...PROVIDER_RESTART_EFFECT, message: 'Provider change restart' }],
|
|
87
|
-
};
|
|
88
|
-
},
|
|
89
|
-
cancel: () => ({ message: 'Provider change cancelled.', success: true }),
|
|
69
|
+
effects: [{ type: 'provider-hot-swap-requested', profileName }],
|
|
90
70
|
};
|
|
91
71
|
}
|
|
92
72
|
|
|
@@ -176,6 +156,16 @@ function completeProviderEdit(
|
|
|
176
156
|
if (!currentProfile) {
|
|
177
157
|
return { message: `Provider profile "${profileName}" was not found.`, success: false };
|
|
178
158
|
}
|
|
159
|
+
const { orgPolicy } = options;
|
|
160
|
+
if (orgPolicy?.requireApiKeyFromEnv && isApiKeyPlaintext(input.apiKey)) {
|
|
161
|
+
return {
|
|
162
|
+
message: formatOrgPolicyViolationMessage(
|
|
163
|
+
'Your organization policy requires API keys to be stored as environment variable references ($ENV:VAR_NAME), not as plaintext.',
|
|
164
|
+
orgPolicy.adminContact,
|
|
165
|
+
),
|
|
166
|
+
success: false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
179
169
|
const target = options.settings.readTargetSettings();
|
|
180
170
|
const patch = buildProviderSetupPatch(input, {
|
|
181
171
|
providerDefinitions: options.providerDefinitions,
|
|
@@ -190,11 +180,9 @@ function completeProviderEdit(
|
|
|
190
180
|
const isCurrent = merged.currentProvider === profileName;
|
|
191
181
|
return {
|
|
192
182
|
message: isCurrent
|
|
193
|
-
? `Provider ${profileName} updated.
|
|
183
|
+
? `Provider ${profileName} updated. Switching...`
|
|
194
184
|
: `Provider ${profileName} updated.`,
|
|
195
185
|
success: true,
|
|
196
|
-
...(isCurrent
|
|
197
|
-
? { effects: [{ ...PROVIDER_RESTART_EFFECT, message: 'Provider edit restart' }] }
|
|
198
|
-
: {}),
|
|
186
|
+
...(isCurrent ? { effects: [{ type: 'provider-hot-swap-requested', profileName }] } : {}),
|
|
199
187
|
};
|
|
200
188
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { TPromptInput } from './provider-setup-flow.js';
|
|
2
|
+
import type { ITerminalOutput } from '@robota-sdk/agent-core';
|
|
3
|
+
|
|
4
|
+
export type TOnboardingPath = 'has-key' | 'free-key' | 'local';
|
|
5
|
+
|
|
6
|
+
export interface IOnboardingResult {
|
|
7
|
+
path: TOnboardingPath;
|
|
8
|
+
preselectedType?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ONBOARDING_PROMPT = `
|
|
12
|
+
Do you have an API key for an AI provider?
|
|
13
|
+
|
|
14
|
+
1. Yes, I have an API key
|
|
15
|
+
2. No — get a free key (Google Gemini, takes ~2 min)
|
|
16
|
+
3. No — use a local model (LM Studio, no API key needed)
|
|
17
|
+
|
|
18
|
+
Choose [1-3] (default: 1): `;
|
|
19
|
+
|
|
20
|
+
const GEMINI_GUIDE = `
|
|
21
|
+
── Get a free Gemini API key ────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
1. Open https://aistudio.google.com/apikey in your browser
|
|
24
|
+
2. Sign in with your Google account
|
|
25
|
+
3. Click "Create API key" (takes ~30 seconds)
|
|
26
|
+
4. Copy the key starting with "AIza..."
|
|
27
|
+
|
|
28
|
+
─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const LOCAL_MODEL_GUIDE = `
|
|
32
|
+
── Set up a local model with LM Studio ──────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
1. Download LM Studio from https://lmstudio.ai
|
|
35
|
+
2. Open LM Studio → search for a model (e.g. "llama" or "phi") → Download
|
|
36
|
+
3. Go to Developer tab → click Start Server
|
|
37
|
+
(default address: http://localhost:1234)
|
|
38
|
+
|
|
39
|
+
When the server is running, come back here and press Enter to continue.
|
|
40
|
+
|
|
41
|
+
─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
export async function runOnboardingBranch(
|
|
45
|
+
promptInput: TPromptInput,
|
|
46
|
+
terminal: ITerminalOutput,
|
|
47
|
+
): Promise<IOnboardingResult> {
|
|
48
|
+
const raw = await promptInput(ONBOARDING_PROMPT);
|
|
49
|
+
const choice = raw.trim() || '1';
|
|
50
|
+
|
|
51
|
+
if (choice === '2') {
|
|
52
|
+
terminal.writeLine(GEMINI_GUIDE);
|
|
53
|
+
await promptInput(' Press Enter when you have your API key: ');
|
|
54
|
+
return { path: 'free-key', preselectedType: 'gemini' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (choice === '3') {
|
|
58
|
+
terminal.writeLine(LOCAL_MODEL_GUIDE);
|
|
59
|
+
await promptInput(' Press Enter when LM Studio server is running: ');
|
|
60
|
+
return { path: 'local', preselectedType: 'gemma' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { path: 'has-key' };
|
|
64
|
+
}
|
|
@@ -177,12 +177,21 @@ export function formatProviderSetupPromptLabel(
|
|
|
177
177
|
return `${prefix} ${step.title}${suffix}: `;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
const CATEGORY_BADGES: Record<string, string> = {
|
|
181
|
+
'cloud-paid': '[Cloud/Paid]',
|
|
182
|
+
'cloud-free': '[Cloud/Free]',
|
|
183
|
+
'local-free': '[Local/Free]',
|
|
184
|
+
};
|
|
185
|
+
|
|
180
186
|
export function formatProviderSetupChoiceLabel(definition: IProviderDefinition): string {
|
|
181
|
-
const
|
|
187
|
+
const badge =
|
|
188
|
+
definition.category !== undefined ? `${CATEGORY_BADGES[definition.category] ?? ''} ` : '';
|
|
189
|
+
const name =
|
|
182
190
|
definition.displayName !== undefined
|
|
183
191
|
? `${definition.displayName} (${definition.type})`
|
|
184
192
|
: definition.type;
|
|
185
|
-
|
|
193
|
+
const label = `${badge}${name}`;
|
|
194
|
+
return definition.description !== undefined ? `${label} — ${definition.description}` : label;
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
export function formatProviderSetupHelpLinks(
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
applyProviderConfiguration,
|
|
11
11
|
} from '@robota-sdk/agent-framework';
|
|
12
12
|
|
|
13
|
+
import { runOnboardingBranch } from './provider-onboarding.js';
|
|
13
14
|
import {
|
|
14
15
|
formatProviderSetupSelectionPrompt,
|
|
15
16
|
resolveProviderSetupSelection,
|
|
@@ -38,11 +39,22 @@ export async function runProviderStartupSetup(
|
|
|
38
39
|
terminal: ITerminalOutput,
|
|
39
40
|
providerDefinitions: readonly IProviderDefinition[],
|
|
40
41
|
): Promise<void> {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
42
|
+
const onboarding = await runOnboardingBranch(promptInput, terminal);
|
|
43
|
+
const existingProfileNames = Object.keys(readMergedProviderSettings(cwd).providers ?? {});
|
|
43
44
|
const settingsPath = resolveSettingsPathForScope(cwd, ctx.settingsScope);
|
|
45
|
+
|
|
46
|
+
let type: string;
|
|
47
|
+
if (onboarding.preselectedType !== undefined) {
|
|
48
|
+
type = onboarding.preselectedType;
|
|
49
|
+
} else {
|
|
50
|
+
const providerChoice = await promptInput(
|
|
51
|
+
formatProviderSetupSelectionPrompt(providerDefinitions),
|
|
52
|
+
);
|
|
53
|
+
type = resolveProviderSetupSelection(providerChoice, providerDefinitions);
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
const input = await runProviderSetupPromptFlow(type, promptInput, providerDefinitions, {
|
|
45
|
-
existingProfileNames
|
|
57
|
+
existingProfileNames,
|
|
46
58
|
});
|
|
47
59
|
applyProviderConfiguration(settingsPath, input, { providerDefinitions });
|
|
48
60
|
const language = await promptInput(' Response language (ko/en/ja/zh, default: en): ');
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { calculateCost, formatTokens, formatUsd } from '../model-pricing.js';
|
|
3
|
+
|
|
4
|
+
describe('calculateCost', () => {
|
|
5
|
+
it('returns exact cost for known Anthropic Sonnet model', () => {
|
|
6
|
+
const cost = calculateCost('claude-sonnet-4-5', 1_000_000, 1_000_000);
|
|
7
|
+
expect(cost).toBeCloseTo(3 + 15, 5);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns exact cost for known Anthropic Opus model', () => {
|
|
11
|
+
const cost = calculateCost('claude-opus-4-7', 1_000_000, 1_000_000);
|
|
12
|
+
expect(cost).toBeCloseTo(15 + 75, 5);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('calculates fractional token costs correctly', () => {
|
|
16
|
+
const cost = calculateCost('claude-sonnet-4-5', 45_000, 12_000);
|
|
17
|
+
expect(cost).toBeDefined();
|
|
18
|
+
expect(cost!).toBeCloseTo((45_000 / 1_000_000) * 3 + (12_000 / 1_000_000) * 15, 8);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('falls back to pattern matching for claude-sonnet variant', () => {
|
|
22
|
+
const cost = calculateCost('claude-sonnet-99-99', 1_000_000, 0);
|
|
23
|
+
expect(cost).toBeCloseTo(3, 5);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('falls back to pattern matching for deepseek variant', () => {
|
|
27
|
+
const cost = calculateCost('deepseek-v3-turbo', 1_000_000, 0);
|
|
28
|
+
expect(cost).toBeDefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns undefined for completely unknown model', () => {
|
|
32
|
+
const cost = calculateCost('unknown-model-xyz-123', 1_000, 1_000);
|
|
33
|
+
expect(cost).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns zero cost when both token counts are zero', () => {
|
|
37
|
+
const cost = calculateCost('claude-sonnet-4-5', 0, 0);
|
|
38
|
+
expect(cost).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('formatUsd', () => {
|
|
43
|
+
it('formats small amounts with 4 decimal places', () => {
|
|
44
|
+
expect(formatUsd(0.0043)).toBe('$0.0043');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('formats sub-dollar amounts with 3 decimal places', () => {
|
|
48
|
+
expect(formatUsd(0.187)).toBe('$0.187');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('formats dollar+ amounts with 2 decimal places', () => {
|
|
52
|
+
expect(formatUsd(1.24)).toBe('$1.24');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('formatTokens', () => {
|
|
57
|
+
it('formats token counts with comma separators', () => {
|
|
58
|
+
expect(formatTokens(45000)).toBe('45,000');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('formats small counts without separator', () => {
|
|
62
|
+
expect(formatTokens(999)).toBe('999');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -139,7 +139,7 @@ describe('createSessionCommandModule', () => {
|
|
|
139
139
|
expect(entry).toEqual(
|
|
140
140
|
expect.objectContaining({
|
|
141
141
|
name: 'cost',
|
|
142
|
-
description: '
|
|
142
|
+
description: expect.stringContaining('token usage'),
|
|
143
143
|
source: 'session',
|
|
144
144
|
modelInvocable: false,
|
|
145
145
|
}),
|
|
@@ -288,18 +288,62 @@ describe('createSessionCommandModule', () => {
|
|
|
288
288
|
});
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
-
it('shows session info through the session command API', async () => {
|
|
291
|
+
it('shows session info through the session command API (no token data yet)', async () => {
|
|
292
292
|
const executor = new SystemCommandExecutor([
|
|
293
293
|
...(createSessionCommandModule().systemCommands ?? []),
|
|
294
294
|
]);
|
|
295
295
|
|
|
296
296
|
const result = await executor.execute('cost', createCommandContext(), '');
|
|
297
297
|
|
|
298
|
-
expect(result).
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
298
|
+
expect(result?.success).toBe(true);
|
|
299
|
+
expect(result?.message).toContain('session_1');
|
|
300
|
+
expect(result?.message).toContain('Messages: 5');
|
|
301
|
+
expect(result?.message).toContain('not yet available');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('shows token counts and estimated cost when session has usage data', async () => {
|
|
305
|
+
const runtime = {
|
|
306
|
+
...createRuntime(),
|
|
307
|
+
getSessionTokenUsage: () => ({ inputTokens: 45_000, outputTokens: 12_000 }),
|
|
308
|
+
getModelId: () => 'claude-sonnet-4-5',
|
|
309
|
+
};
|
|
310
|
+
const context = { ...createCommandContext(), getSession: () => runtime };
|
|
311
|
+
const executor = new SystemCommandExecutor([
|
|
312
|
+
...(createSessionCommandModule().systemCommands ?? []),
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
const result = await executor.execute('cost', context, '');
|
|
316
|
+
|
|
317
|
+
expect(result?.success).toBe(true);
|
|
318
|
+
expect(result?.message).toContain('45,000');
|
|
319
|
+
expect(result?.message).toContain('12,000');
|
|
320
|
+
expect(result?.message).toContain('$');
|
|
321
|
+
expect((result?.data as Record<string, unknown>)?.inputTokens).toBe(45_000);
|
|
322
|
+
expect((result?.data as Record<string, unknown>)?.outputTokens).toBe(12_000);
|
|
323
|
+
expect((result?.data as Record<string, unknown>)?.estimatedCostUsd).toBeDefined();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('sets a monthly budget via /cost budget subcommand', async () => {
|
|
327
|
+
const executor = new SystemCommandExecutor([
|
|
328
|
+
...(createSessionCommandModule().systemCommands ?? []),
|
|
329
|
+
]);
|
|
330
|
+
const context = { ...createCommandContext(), getCwd: () => '/tmp/robota-test-budget' };
|
|
331
|
+
|
|
332
|
+
const result = await executor.execute('cost', context, 'budget 5.00');
|
|
333
|
+
|
|
334
|
+
expect(result?.success).toBe(true);
|
|
335
|
+
expect(result?.message).toContain('$5.00');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('rejects invalid budget amount', async () => {
|
|
339
|
+
const executor = new SystemCommandExecutor([
|
|
340
|
+
...(createSessionCommandModule().systemCommands ?? []),
|
|
341
|
+
]);
|
|
342
|
+
|
|
343
|
+
const result = await executor.execute('cost', createCommandContext(), 'budget notanumber');
|
|
344
|
+
|
|
345
|
+
expect(result?.success).toBe(false);
|
|
346
|
+
expect(result?.message).toContain('Usage:');
|
|
303
347
|
});
|
|
304
348
|
|
|
305
349
|
it('validates the current session replay log through the SDK common API', async () => {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** USD prices per 1,000,000 tokens (as of May 2026 — update when providers change rates). */
|
|
2
|
+
interface IModelPrice {
|
|
3
|
+
inputPerMillion: number;
|
|
4
|
+
outputPerMillion: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const MODEL_PRICES: Record<string, IModelPrice> = {
|
|
8
|
+
// Anthropic Claude 4
|
|
9
|
+
'claude-opus-4-7': { inputPerMillion: 15, outputPerMillion: 75 },
|
|
10
|
+
'claude-opus-4-5': { inputPerMillion: 15, outputPerMillion: 75 },
|
|
11
|
+
'claude-sonnet-4-6': { inputPerMillion: 3, outputPerMillion: 15 },
|
|
12
|
+
'claude-sonnet-4-5': { inputPerMillion: 3, outputPerMillion: 15 },
|
|
13
|
+
'claude-haiku-4-5': { inputPerMillion: 0.8, outputPerMillion: 4 },
|
|
14
|
+
// Anthropic Claude 3
|
|
15
|
+
'claude-3-5-sonnet-20241022': { inputPerMillion: 3, outputPerMillion: 15 },
|
|
16
|
+
'claude-3-5-haiku-20241022': { inputPerMillion: 0.8, outputPerMillion: 4 },
|
|
17
|
+
'claude-3-opus-20240229': { inputPerMillion: 15, outputPerMillion: 75 },
|
|
18
|
+
// OpenAI
|
|
19
|
+
'gpt-4o': { inputPerMillion: 2.5, outputPerMillion: 10 },
|
|
20
|
+
'gpt-4o-mini': { inputPerMillion: 0.15, outputPerMillion: 0.6 },
|
|
21
|
+
o1: { inputPerMillion: 15, outputPerMillion: 60 },
|
|
22
|
+
'o1-mini': { inputPerMillion: 3, outputPerMillion: 12 },
|
|
23
|
+
o3: { inputPerMillion: 10, outputPerMillion: 40 },
|
|
24
|
+
'o3-mini': { inputPerMillion: 1.1, outputPerMillion: 4.4 },
|
|
25
|
+
// DeepSeek
|
|
26
|
+
'deepseek-chat': { inputPerMillion: 0.14, outputPerMillion: 0.28 },
|
|
27
|
+
'deepseek-reasoner': { inputPerMillion: 0.55, outputPerMillion: 2.19 },
|
|
28
|
+
// Google Gemini
|
|
29
|
+
'gemini-2.0-flash': { inputPerMillion: 0.1, outputPerMillion: 0.4 },
|
|
30
|
+
'gemini-2.0-flash-thinking': { inputPerMillion: 0.35, outputPerMillion: 3.5 },
|
|
31
|
+
'gemini-1.5-pro': { inputPerMillion: 1.25, outputPerMillion: 5 },
|
|
32
|
+
'gemini-1.5-flash': { inputPerMillion: 0.075, outputPerMillion: 0.3 },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const PATTERN_PRICES: Array<{ pattern: RegExp; price: IModelPrice }> = [
|
|
36
|
+
{ pattern: /claude-opus/i, price: { inputPerMillion: 15, outputPerMillion: 75 } },
|
|
37
|
+
{ pattern: /claude-sonnet/i, price: { inputPerMillion: 3, outputPerMillion: 15 } },
|
|
38
|
+
{ pattern: /claude-haiku/i, price: { inputPerMillion: 0.8, outputPerMillion: 4 } },
|
|
39
|
+
{ pattern: /gpt-4o-mini/i, price: { inputPerMillion: 0.15, outputPerMillion: 0.6 } },
|
|
40
|
+
{ pattern: /gpt-4/i, price: { inputPerMillion: 2.5, outputPerMillion: 10 } },
|
|
41
|
+
{ pattern: /deepseek/i, price: { inputPerMillion: 0.14, outputPerMillion: 0.28 } },
|
|
42
|
+
{ pattern: /gemini-2/i, price: { inputPerMillion: 0.1, outputPerMillion: 0.4 } },
|
|
43
|
+
{ pattern: /gemini-1/i, price: { inputPerMillion: 1.25, outputPerMillion: 5 } },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function lookupPrice(modelId: string): IModelPrice | undefined {
|
|
47
|
+
const exact = MODEL_PRICES[modelId];
|
|
48
|
+
if (exact) return exact;
|
|
49
|
+
for (const { pattern, price } of PATTERN_PRICES) {
|
|
50
|
+
if (pattern.test(modelId)) return price;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Returns USD cost, or undefined if the model is not in the pricing table. */
|
|
56
|
+
export function calculateCost(
|
|
57
|
+
modelId: string,
|
|
58
|
+
inputTokens: number,
|
|
59
|
+
outputTokens: number,
|
|
60
|
+
): number | undefined {
|
|
61
|
+
const price = lookupPrice(modelId);
|
|
62
|
+
if (!price) return undefined;
|
|
63
|
+
return (
|
|
64
|
+
(inputTokens / 1_000_000) * price.inputPerMillion +
|
|
65
|
+
(outputTokens / 1_000_000) * price.outputPerMillion
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Format a USD amount for display (e.g. "$0.0043"). */
|
|
70
|
+
export function formatUsd(amount: number): string {
|
|
71
|
+
if (amount < 0.01) return `$${amount.toFixed(4)}`;
|
|
72
|
+
if (amount < 1) return `$${amount.toFixed(3)}`;
|
|
73
|
+
return `$${amount.toFixed(2)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Format a token count with comma separators (e.g. "45,000"). */
|
|
77
|
+
export function formatTokens(count: number): string {
|
|
78
|
+
return count.toLocaleString('en-US');
|
|
79
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
1
4
|
import {
|
|
2
5
|
RENAME_COMMAND_USAGE,
|
|
3
6
|
clearConversationHistory,
|
|
@@ -9,6 +12,8 @@ import {
|
|
|
9
12
|
validateCommandSessionReplayLog,
|
|
10
13
|
} from '@robota-sdk/agent-framework';
|
|
11
14
|
|
|
15
|
+
import { calculateCost, formatTokens, formatUsd } from './model-pricing.js';
|
|
16
|
+
|
|
12
17
|
import type { ICommandHostContext, ICommandResult } from '@robota-sdk/agent-framework';
|
|
13
18
|
|
|
14
19
|
export const CLEAR_COMMAND_MESSAGE = 'Conversation cleared.';
|
|
@@ -45,16 +50,127 @@ export function executeResumeCommand(_context: ICommandHostContext, _args: strin
|
|
|
45
50
|
};
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
|
|
53
|
+
const BUDGET_FILE = '.robota/budget.json';
|
|
54
|
+
|
|
55
|
+
interface IBudgetConfig {
|
|
56
|
+
monthly: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readBudget(cwd: string): IBudgetConfig | undefined {
|
|
60
|
+
const file = join(cwd, BUDGET_FILE);
|
|
61
|
+
if (!existsSync(file)) return undefined;
|
|
62
|
+
let raw: string;
|
|
63
|
+
try {
|
|
64
|
+
raw = readFileSync(file, 'utf-8');
|
|
65
|
+
} catch {
|
|
66
|
+
// allow-fallback: budget file read failure disables feature gracefully
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(raw) as IBudgetConfig;
|
|
71
|
+
} catch {
|
|
72
|
+
// allow-fallback: malformed budget JSON treated as no budget set
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeBudget(cwd: string, config: IBudgetConfig): void {
|
|
78
|
+
const dir = join(cwd, '.robota');
|
|
79
|
+
mkdirSync(dir, { recursive: true });
|
|
80
|
+
writeFileSync(join(cwd, BUDGET_FILE), JSON.stringify(config, null, 2));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clearBudget(cwd: string): void {
|
|
84
|
+
const file = join(cwd, BUDGET_FILE);
|
|
85
|
+
if (existsSync(file)) {
|
|
86
|
+
writeFileSync(file, '{}');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildCostOutput(context: ICommandHostContext): {
|
|
91
|
+
lines: string[];
|
|
92
|
+
data: Record<string, unknown>;
|
|
93
|
+
} {
|
|
94
|
+
const session = context.getSession();
|
|
49
95
|
const sessionInfo = readCommandSessionInfo(context);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
96
|
+
const tokenUsage = session.getSessionTokenUsage?.();
|
|
97
|
+
const modelId = session.getModelId?.();
|
|
98
|
+
const lines: string[] = [
|
|
99
|
+
`Session: ${sessionInfo.sessionId}`,
|
|
100
|
+
`Messages: ${sessionInfo.messageCount}`,
|
|
101
|
+
];
|
|
102
|
+
const data: Record<string, unknown> = {
|
|
103
|
+
sessionId: sessionInfo.sessionId,
|
|
104
|
+
messageCount: sessionInfo.messageCount,
|
|
57
105
|
};
|
|
106
|
+
|
|
107
|
+
if (tokenUsage) {
|
|
108
|
+
lines.push(
|
|
109
|
+
`Tokens: ${formatTokens(tokenUsage.inputTokens)} input / ${formatTokens(tokenUsage.outputTokens)} output`,
|
|
110
|
+
);
|
|
111
|
+
data.inputTokens = tokenUsage.inputTokens;
|
|
112
|
+
data.outputTokens = tokenUsage.outputTokens;
|
|
113
|
+
|
|
114
|
+
if (modelId) {
|
|
115
|
+
const cost = calculateCost(modelId, tokenUsage.inputTokens, tokenUsage.outputTokens);
|
|
116
|
+
if (cost !== undefined) {
|
|
117
|
+
lines.push(`Cost: ${formatUsd(cost)} (${modelId})`);
|
|
118
|
+
data.estimatedCostUsd = cost;
|
|
119
|
+
|
|
120
|
+
const budget = readBudget(context.getCwd());
|
|
121
|
+
if (budget?.monthly) {
|
|
122
|
+
const remaining = budget.monthly - cost;
|
|
123
|
+
const pct = Math.min(100, Math.round((cost / budget.monthly) * 100));
|
|
124
|
+
lines.push(
|
|
125
|
+
`Budget: ${formatUsd(remaining)} remaining of ${formatUsd(budget.monthly)}/mo (${pct}% used)`,
|
|
126
|
+
);
|
|
127
|
+
data.budgetMonthly = budget.monthly;
|
|
128
|
+
data.budgetRemainingUsd = remaining;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
lines.push('Tokens: not yet available (no turns completed)');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { lines, data };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function executeCostCommand(context: ICommandHostContext, args: string): ICommandResult {
|
|
140
|
+
const trimmed = args.trim();
|
|
141
|
+
|
|
142
|
+
if (trimmed.startsWith('budget')) {
|
|
143
|
+
const budgetArg = trimmed.slice('budget'.length).trim();
|
|
144
|
+
|
|
145
|
+
if (budgetArg === 'clear') {
|
|
146
|
+
clearBudget(context.getCwd());
|
|
147
|
+
return { success: true, message: 'Monthly budget cleared.' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (budgetArg === '') {
|
|
151
|
+
const current = readBudget(context.getCwd());
|
|
152
|
+
if (!current?.monthly) {
|
|
153
|
+
return {
|
|
154
|
+
success: true,
|
|
155
|
+
message: 'No budget set. Use: /cost budget <amount>',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return { success: true, message: `Monthly budget: ${formatUsd(current.monthly)}` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const amount = parseFloat(budgetArg);
|
|
162
|
+
if (!Number.isFinite(amount) || amount <= 0) {
|
|
163
|
+
return {
|
|
164
|
+
success: false,
|
|
165
|
+
message: 'Usage: /cost budget <amount> (e.g. /cost budget 5.00)',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
writeBudget(context.getCwd(), { monthly: amount });
|
|
169
|
+
return { success: true, message: `Monthly budget set to ${formatUsd(amount)}.` };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { lines, data } = buildCostOutput(context);
|
|
173
|
+
return { success: true, message: lines.join('\n'), data };
|
|
58
174
|
}
|
|
59
175
|
|
|
60
176
|
export function executeValidateSessionCommand(
|