Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.cache
*.tsbuildinfo

# app generated files
apps/desktop/.svelte-kit
apps/desktop/.svelte_kit
apps/desktop/build
apps/desktop/src-tauri/target
apps/desktop/src-tauri/gen/
apps/web/.svelte-kit
apps/web/.svelte_kit

# IntelliJ based IDEs
.idea

Expand Down
14 changes: 7 additions & 7 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,23 +47,23 @@
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@btca/shared": "workspace:*",
"@effect/platform-bun": "4.0.0-beta.20",
"@inquirer/select": "^5.0.4",
"@opentui/core": "0.1.77",
"@opentui/react": "0.1.77",
"@opentui/core": "0.1.86",
"@opentui/react": "0.1.86",
"@tmcp/adapter-zod": "^0.1.7",
"@tmcp/transport-stdio": "^0.4.1",
"@types/bun": "latest",
"@types/node": "22",
"@types/node": "22.19.15",
"@types/react": "^19.2.13",
"@typescript/native-preview": "^7.0.0-dev.20260109.1",
"babel-plugin-react-compiler": "^1.0.0",
"btca-server": "workspace:*",
"effect": "4.0.0-beta.20",
"prettier": "^3.7.4",
"react": "^19.2.4",
"tmcp": "^1.19.2",
"web-tree-sitter": "0.25.10",
"zod": "^4.3.6",
"@effect/platform-bun": "4.0.0-beta.20",
"effect": "4.0.0-beta.20"
"web-tree-sitter": "0.26.6",
"zod": "^4.3.6"
}
}
15 changes: 10 additions & 5 deletions apps/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ async function fileExists(filePath: string): Promise<boolean> {
async function handleCliSetup(cwd: string, configPath: string, force?: boolean): Promise<void> {
if (await fileExists(configPath)) {
if (!force) {
throw new Error(`${PROJECT_CONFIG_FILENAME} already exists. Use --force to overwrite.`);
throw new Error(
`${PROJECT_CONFIG_FILENAME} already exists at ${configPath}. Use --force to overwrite.`
);
}
console.log(`\nOverwriting existing ${PROJECT_CONFIG_FILENAME}...`);
}
Expand Down Expand Up @@ -170,8 +172,11 @@ async function handleCliSetup(cwd: string, configPath: string, force?: boolean):
}

export const runInitCommand = (args: { force?: boolean }) =>
Effect.tryPromise(async () => {
const cwd = process.cwd();
const configPath = path.join(cwd, PROJECT_CONFIG_FILENAME);
await handleCliSetup(cwd, configPath, args.force);
Effect.tryPromise({
try: async () => {
const cwd = process.cwd();
const configPath = path.join(cwd, PROJECT_CONFIG_FILENAME);
await handleCliSetup(cwd, configPath, args.force);
},
catch: (error) => error
});
36 changes: 33 additions & 3 deletions apps/cli/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ const CLI_DIR = fileURLToPath(new URL('..', import.meta.url));
const textFromProcessOutput = (value: Uint8Array | string | undefined) =>
typeof value === 'string' ? value : value ? new TextDecoder().decode(value) : '';

const runCli = (argv: string[], timeout = 10_000, env?: Record<string, string>) => {
const runCliAtCwd = (
argv: string[],
cwd: string,
timeout = 10_000,
env?: Record<string, string>
) => {
const result = Bun.spawnSync({
cmd: ['bun', 'run', 'src/index.ts', ...argv],
cwd: CLI_DIR,
cmd: ['bun', 'run', path.join(CLI_DIR, 'src/index.ts'), ...argv],
cwd,
stdout: 'pipe',
stderr: 'pipe',
timeout,
Expand All @@ -25,6 +30,9 @@ const runCli = (argv: string[], timeout = 10_000, env?: Record<string, string>)
};
};

const runCli = (argv: string[], timeout = 10_000, env?: Record<string, string>) =>
runCliAtCwd(argv, CLI_DIR, timeout, env);

const withTempHome = async <T>(run: (tempHome: string) => Promise<T>): Promise<T> => {
const tempHome = mkdtempSync(path.join(tmpdir(), 'btca-cli-test-'));
const originalHome = process.env.HOME;
Expand All @@ -37,6 +45,15 @@ const withTempHome = async <T>(run: (tempHome: string) => Promise<T>): Promise<T
}
};

const withTempDir = async <T>(run: (tempDir: string) => Promise<T>): Promise<T> => {
const tempDir = mkdtempSync(path.join(tmpdir(), 'btca-cli-cwd-'));
try {
return await run(tempDir);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
};

const createStubServer = (handlers?: Partial<Record<string, (request: Request) => Response>>) => {
const requestPaths: string[] = [];
const defaultHandlers: Record<string, (request: Request) => Response> = {
Expand Down Expand Up @@ -104,6 +121,19 @@ describe('cli dispatch', () => {
expect(result.output).toContain('Unknown subcommand "foo" for "btca telemetry"');
});

test('preserves helpful error when init config already exists', async () => {
await withTempDir(async (tempDir) => {
const configPath = path.join(tempDir, 'btca.config.jsonc');
await Bun.write(configPath, '{}');

const result = runCliAtCwd(['init'], tempDir);
expect(result.exitCode).toBe(1);
expect(result.output).toContain('btca.config.jsonc already exists at ');
expect(result.output).toContain('btca.config.jsonc. Use --force to overwrite.');
expect(result.output).not.toContain('An error occurred in Effect.tryPromise');
});
});

test('forwards subcommand --server to resources command', async () => {
const stub = createStubServer();
try {
Expand Down
2 changes: 1 addition & 1 deletion apps/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@
"prettier": "^3.7.4"
},
"dependencies": {
"@daytonaio/sdk": "^0.130.0"
"@daytonaio/sdk": "^0.149.0"
}
}
2 changes: 1 addition & 1 deletion apps/sandbox/src/shared.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// Snapshot name for btca sandbox
export const BTCA_SNAPSHOT_NAME = 'btca-app-sandbox-3';
export const BTCA_SNAPSHOT_NAME = 'btca-app-sandbox-4';
4 changes: 2 additions & 2 deletions apps/sandbox/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ async function main(): Promise<void> {
name: BTCA_SNAPSHOT_NAME,
image,
resources: {
cpu: 2,
memory: 4,
cpu: 1,
memory: 3,
disk: 5
}
},
Expand Down
4 changes: 2 additions & 2 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@
"@effect/platform-bun": "4.0.0-beta.20",
"ai": "^6.0.49",
"effect": "4.0.0-beta.20",
"just-bash": "^2.7.0",
"opencode-ai": "^1.1.36",
"just-bash": "^2.12.4",
"opencode-ai": "^1.2.21",
"vercel-minimax-ai-provider": "^0.0.2",
"zod": "^3.25.76"
}
Expand Down
73 changes: 49 additions & 24 deletions apps/server/src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export type AgentEvent =
inputTokens?: number;
outputTokens?: number;
reasoningTokens?: number;
cachedTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
totalTokens?: number;
};
}
Expand Down Expand Up @@ -223,18 +226,29 @@ export const runAgentLoop = async (options: AgentLoopOptions): Promise<AgentLoop
});
break;
case 'finish':
events.push({
type: 'finish',
finishReason: part.finishReason ?? 'unknown',
usage: {
inputTokens: part.totalUsage?.inputTokens,
outputTokens: part.totalUsage?.outputTokens,
reasoningTokens:
part.totalUsage?.outputTokenDetails?.reasoningTokens ??
part.totalUsage?.reasoningTokens,
totalTokens: part.totalUsage?.totalTokens
}
});
{
const cacheReadTokens = part.totalUsage?.inputTokenDetails?.cacheReadTokens;
const cacheWriteTokens = part.totalUsage?.inputTokenDetails?.cacheWriteTokens;
events.push({
type: 'finish',
finishReason: part.finishReason ?? 'unknown',
usage: {
inputTokens:
part.totalUsage?.inputTokenDetails?.noCacheTokens ?? part.totalUsage?.inputTokens,
outputTokens: part.totalUsage?.outputTokens,
reasoningTokens:
part.totalUsage?.outputTokenDetails?.reasoningTokens ??
part.totalUsage?.reasoningTokens,
cachedTokens:
cacheReadTokens != null || cacheWriteTokens != null
? (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0)
: part.totalUsage?.cachedInputTokens,
cacheReadTokens,
cacheWriteTokens,
totalTokens: part.totalUsage?.totalTokens
}
});
}
break;
case 'error':
events.push({
Expand Down Expand Up @@ -316,18 +330,29 @@ export async function* streamAgentLoop(options: AgentLoopOptions): AsyncGenerato
};
break;
case 'finish':
yield {
type: 'finish',
finishReason: part.finishReason ?? 'unknown',
usage: {
inputTokens: part.totalUsage?.inputTokens,
outputTokens: part.totalUsage?.outputTokens,
reasoningTokens:
part.totalUsage?.outputTokenDetails?.reasoningTokens ??
part.totalUsage?.reasoningTokens,
totalTokens: part.totalUsage?.totalTokens
}
};
{
const cacheReadTokens = part.totalUsage?.inputTokenDetails?.cacheReadTokens;
const cacheWriteTokens = part.totalUsage?.inputTokenDetails?.cacheWriteTokens;
yield {
type: 'finish',
finishReason: part.finishReason ?? 'unknown',
usage: {
inputTokens:
part.totalUsage?.inputTokenDetails?.noCacheTokens ?? part.totalUsage?.inputTokens,
outputTokens: part.totalUsage?.outputTokens,
reasoningTokens:
part.totalUsage?.outputTokenDetails?.reasoningTokens ??
part.totalUsage?.reasoningTokens,
cachedTokens:
cacheReadTokens != null || cacheWriteTokens != null
? (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0)
: part.totalUsage?.cachedInputTokens,
cacheReadTokens,
cacheWriteTokens,
totalTokens: part.totalUsage?.totalTokens
}
};
}
break;
case 'error':
yield {
Expand Down
22 changes: 17 additions & 5 deletions apps/server/src/stream/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,12 @@ describe('createSseStream', () => {
type: 'finish',
finishReason: 'stop',
usage: {
inputTokens: 1_000_000,
inputTokens: 750_000,
outputTokens: 2_000_000,
reasoningTokens: 250_000,
cachedTokens: 250_000,
cacheReadTokens: 200_000,
cacheWriteTokens: 50_000,
totalTokens: 3_250_000
}
} as const;
Expand All @@ -84,7 +87,13 @@ describe('createSseStream', () => {
lookup: async () => ({
source: 'models.dev' as const,
modelKey: 'openai/gpt-4o-mini',
ratesUsdPerMTokens: { input: 1, output: 2, reasoning: 0.5 }
ratesUsdPerMTokens: {
input: 1,
output: 2,
reasoning: 0.5,
cacheRead: 0.25,
cacheWrite: 1.5
}
})
}
});
Expand All @@ -97,9 +106,12 @@ describe('createSseStream', () => {

if (doneEvent?.type !== 'done') throw new Error('missing done event');

expect(doneEvent.usage?.inputTokens).toBe(1_000_000);
expect(doneEvent.usage?.inputTokens).toBe(750_000);
expect(doneEvent.usage?.outputTokens).toBe(2_000_000);
expect(doneEvent.usage?.reasoningTokens).toBe(250_000);
expect(doneEvent.usage?.cachedTokens).toBe(250_000);
expect(doneEvent.usage?.cacheReadTokens).toBe(200_000);
expect(doneEvent.usage?.cacheWriteTokens).toBe(50_000);
expect(doneEvent.usage?.totalTokens).toBe(3_250_000);

expect(typeof doneEvent.metrics?.timing?.totalMs).toBe('number');
Expand All @@ -113,8 +125,8 @@ describe('createSseStream', () => {
expect(doneEvent.metrics?.pricing?.modelKey).toBe('openai/gpt-4o-mini');
expect(doneEvent.metrics?.pricing?.ratesUsdPerMTokens?.input).toBe(1);

// cost = (1.0 * 1) + (2.0 * 2) + (0.25 * 0.5) = 5.125
expect(doneEvent.metrics?.pricing?.costUsd?.total).toBeCloseTo(5.125, 8);
// cost = (0.75 * 1) + (2.0 * 2) + (0.25 * 0.5) + (0.2 * 0.25) + (0.05 * 1.5) = 5
expect(doneEvent.metrics?.pricing?.costUsd?.total).toBeCloseTo(5, 8);
});

it('does not throw if the client cancels before an error is emitted', async () => {
Expand Down
19 changes: 17 additions & 2 deletions apps/server/src/stream/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ export const createSseStream = (args: {
inputTokens: event.usage?.inputTokens,
outputTokens: event.usage?.outputTokens,
reasoningTokens: event.usage?.reasoningTokens,
cachedTokens: event.usage?.cachedTokens,
cacheReadTokens: event.usage?.cacheReadTokens,
cacheWriteTokens: event.usage?.cacheWriteTokens,
totalTokens: event.usage?.totalTokens
}
: undefined;
Expand Down Expand Up @@ -233,8 +236,20 @@ export const createSseStream = (args: {
const input = costFor(usage.inputTokens, rates.input);
const output = costFor(usage.outputTokens, rates.output);
const reasoning = costFor(usage.reasoningTokens, rates.reasoning);
const hasAnyCostPart = input != null || output != null || reasoning != null;
const total = (input ?? 0) + (output ?? 0) + (reasoning ?? 0);
const cacheRead = costFor(usage.cacheReadTokens, rates.cacheRead);
const cacheWrite = costFor(usage.cacheWriteTokens, rates.cacheWrite);
const hasAnyCostPart =
input != null ||
output != null ||
reasoning != null ||
cacheRead != null ||
cacheWrite != null;
const total =
(input ?? 0) +
(output ?? 0) +
(reasoning ?? 0) +
(cacheRead ?? 0) +
(cacheWrite ?? 0);

return {
source: 'models.dev' as const,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/stream/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export const BtcaStreamUsageSchema = z.object({
inputTokens: z.number().optional(),
outputTokens: z.number().optional(),
reasoningTokens: z.number().optional(),
cachedTokens: z.number().optional(),
cacheReadTokens: z.number().optional(),
cacheWriteTokens: z.number().optional(),
totalTokens: z.number().optional()
});

Expand Down
19 changes: 19 additions & 0 deletions apps/web/@useautumn-sdk.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// AUTO-GENERATED by atmn pull
// DO NOT EDIT MANUALLY

declare module '@useautumn/sdk' {
// Features
export const sandbox_hours: Feature;
export const tokens_out: Feature;
export const tokens_in: Feature;
export const chat_messages: Feature;
export const ai_budget: Feature;

// Plans
export const free_plan: Plan;
export const btca_pro: Plan;

// Base types
export type Feature = import('./autumn.config').Feature;
export type Plan = import('./autumn.config').Plan;
}
Loading
Loading