From 6a8f3555ad77d35b1f81b51fc895ba116a5172a6 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Thu, 5 Mar 2026 13:41:59 -0800 Subject: [PATCH 01/11] sandbox size fixes --- apps/sandbox/src/snapshot.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sandbox/src/snapshot.ts b/apps/sandbox/src/snapshot.ts index f9fdeb72..35db194f 100644 --- a/apps/sandbox/src/snapshot.ts +++ b/apps/sandbox/src/snapshot.ts @@ -38,8 +38,8 @@ async function main(): Promise { name: BTCA_SNAPSHOT_NAME, image, resources: { - cpu: 2, - memory: 4, + cpu: 1, + memory: 3, disk: 5 } }, From acf3174dd79cd90b92ab3864902592898b0af29c Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Thu, 5 Mar 2026 14:36:40 -0800 Subject: [PATCH 02/11] sandboxes are not burning cash --- apps/sandbox/src/shared.ts | 2 +- apps/web/src/convex/_generated/api.d.ts | 307 +++++++++-------- apps/web/src/convex/_generated/api.js | 2 +- apps/web/src/convex/_generated/dataModel.d.ts | 22 +- apps/web/src/convex/_generated/server.d.ts | 34 +- apps/web/src/convex/_generated/server.js | 16 +- apps/web/src/convex/http.ts | 38 ++- apps/web/src/convex/instances/actions.ts | 314 +++++++++++++----- apps/web/src/convex/instances/mutations.ts | 30 +- apps/web/src/convex/instances/queries.ts | 11 +- apps/web/src/convex/mcp.ts | 23 +- apps/web/src/convex/schema.ts | 2 + .../src/lib/components/InstanceCard.svelte | 113 +++---- .../src/lib/components/InstanceStatus.svelte | 22 +- .../lib/components/ProvisioningModal.svelte | 3 +- apps/web/src/lib/instanceErrors.ts | 59 ++++ apps/web/src/lib/stores/instance.svelte.ts | 16 +- .../web/src/routes/app/chat/[id]/+page.svelte | 52 +++ 18 files changed, 725 insertions(+), 341 deletions(-) create mode 100644 apps/web/src/lib/instanceErrors.ts diff --git a/apps/sandbox/src/shared.ts b/apps/sandbox/src/shared.ts index a25f2307..2d0c9614 100644 --- a/apps/sandbox/src/shared.ts +++ b/apps/sandbox/src/shared.ts @@ -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'; diff --git a/apps/web/src/convex/_generated/api.d.ts b/apps/web/src/convex/_generated/api.d.ts index 6d941959..7c558585 100644 --- a/apps/web/src/convex/_generated/api.d.ts +++ b/apps/web/src/convex/_generated/api.d.ts @@ -8,72 +8,76 @@ * @module */ -import type * as analytics from '../analytics.js'; -import type * as analyticsEvents from '../analyticsEvents.js'; -import type * as apiHelpers from '../apiHelpers.js'; -import type * as authHelpers from '../authHelpers.js'; -import type * as clerkApiKeys from '../clerkApiKeys.js'; -import type * as clerkApiKeysQueries from '../clerkApiKeysQueries.js'; -import type * as crons from '../crons.js'; -import type * as githubAuth from '../githubAuth.js'; -import type * as githubConnections from '../githubConnections.js'; -import type * as http from '../http.js'; -import type * as instances_actions from '../instances/actions.js'; -import type * as instances_mutations from '../instances/mutations.js'; -import type * as instances_queries from '../instances/queries.js'; -import type * as mcp from '../mcp.js'; -import type * as mcp_resourceContract from '../mcp/resourceContract.js'; -import type * as mcpInternal from '../mcpInternal.js'; -import type * as mcpQuestions from '../mcpQuestions.js'; -import type * as messages from '../messages.js'; -import type * as migrations from '../migrations.js'; -import type * as privateWrappers from '../privateWrappers.js'; -import type * as projects from '../projects.js'; -import type * as resourceActions from '../resourceActions.js'; -import type * as resources from '../resources.js'; -import type * as scheduled_queries from '../scheduled/queries.js'; -import type * as scheduled_updates from '../scheduled/updates.js'; -import type * as scheduled_versionCheck from '../scheduled/versionCheck.js'; -import type * as streamSessions from '../streamSessions.js'; -import type * as threadTitle from '../threadTitle.js'; -import type * as threads from '../threads.js'; -import type * as usage from '../usage.js'; -import type * as users from '../users.js'; +import type * as analytics from "../analytics.js"; +import type * as analyticsEvents from "../analyticsEvents.js"; +import type * as apiHelpers from "../apiHelpers.js"; +import type * as authHelpers from "../authHelpers.js"; +import type * as clerkApiKeys from "../clerkApiKeys.js"; +import type * as clerkApiKeysQueries from "../clerkApiKeysQueries.js"; +import type * as crons from "../crons.js"; +import type * as githubAuth from "../githubAuth.js"; +import type * as githubConnections from "../githubConnections.js"; +import type * as http from "../http.js"; +import type * as instances_actions from "../instances/actions.js"; +import type * as instances_mutations from "../instances/mutations.js"; +import type * as instances_queries from "../instances/queries.js"; +import type * as mcp from "../mcp.js"; +import type * as mcp_resourceContract from "../mcp/resourceContract.js"; +import type * as mcpInternal from "../mcpInternal.js"; +import type * as mcpQuestions from "../mcpQuestions.js"; +import type * as messages from "../messages.js"; +import type * as migrations from "../migrations.js"; +import type * as privateWrappers from "../privateWrappers.js"; +import type * as projects from "../projects.js"; +import type * as resourceActions from "../resourceActions.js"; +import type * as resources from "../resources.js"; +import type * as scheduled_queries from "../scheduled/queries.js"; +import type * as scheduled_updates from "../scheduled/updates.js"; +import type * as scheduled_versionCheck from "../scheduled/versionCheck.js"; +import type * as streamSessions from "../streamSessions.js"; +import type * as threadTitle from "../threadTitle.js"; +import type * as threads from "../threads.js"; +import type * as usage from "../usage.js"; +import type * as users from "../users.js"; -import type { ApiFromModules, FilterApi, FunctionReference } from 'convex/server'; +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; declare const fullApi: ApiFromModules<{ - analytics: typeof analytics; - analyticsEvents: typeof analyticsEvents; - apiHelpers: typeof apiHelpers; - authHelpers: typeof authHelpers; - clerkApiKeys: typeof clerkApiKeys; - clerkApiKeysQueries: typeof clerkApiKeysQueries; - crons: typeof crons; - githubAuth: typeof githubAuth; - githubConnections: typeof githubConnections; - http: typeof http; - 'instances/actions': typeof instances_actions; - 'instances/mutations': typeof instances_mutations; - 'instances/queries': typeof instances_queries; - mcp: typeof mcp; - 'mcp/resourceContract': typeof mcp_resourceContract; - mcpInternal: typeof mcpInternal; - mcpQuestions: typeof mcpQuestions; - messages: typeof messages; - migrations: typeof migrations; - privateWrappers: typeof privateWrappers; - projects: typeof projects; - resourceActions: typeof resourceActions; - resources: typeof resources; - 'scheduled/queries': typeof scheduled_queries; - 'scheduled/updates': typeof scheduled_updates; - 'scheduled/versionCheck': typeof scheduled_versionCheck; - streamSessions: typeof streamSessions; - threadTitle: typeof threadTitle; - threads: typeof threads; - usage: typeof usage; - users: typeof users; + analytics: typeof analytics; + analyticsEvents: typeof analyticsEvents; + apiHelpers: typeof apiHelpers; + authHelpers: typeof authHelpers; + clerkApiKeys: typeof clerkApiKeys; + clerkApiKeysQueries: typeof clerkApiKeysQueries; + crons: typeof crons; + githubAuth: typeof githubAuth; + githubConnections: typeof githubConnections; + http: typeof http; + "instances/actions": typeof instances_actions; + "instances/mutations": typeof instances_mutations; + "instances/queries": typeof instances_queries; + mcp: typeof mcp; + "mcp/resourceContract": typeof mcp_resourceContract; + mcpInternal: typeof mcpInternal; + mcpQuestions: typeof mcpQuestions; + messages: typeof messages; + migrations: typeof migrations; + privateWrappers: typeof privateWrappers; + projects: typeof projects; + resourceActions: typeof resourceActions; + resources: typeof resources; + "scheduled/queries": typeof scheduled_queries; + "scheduled/updates": typeof scheduled_updates; + "scheduled/versionCheck": typeof scheduled_versionCheck; + streamSessions: typeof streamSessions; + threadTitle: typeof threadTitle; + threads: typeof threads; + usage: typeof usage; + users: typeof users; }>; /** @@ -84,7 +88,10 @@ declare const fullApi: ApiFromModules<{ * const myFunctionReference = api.myModule.myFunction; * ``` */ -export declare const api: FilterApi>; +export declare const api: FilterApi< + typeof fullApi, + FunctionReference +>; /** * A utility for referencing Convex functions in your app's internal API. @@ -94,88 +101,96 @@ export declare const api: FilterApi>; +export declare const internal: FilterApi< + typeof fullApi, + FunctionReference +>; export declare const components: { - migrations: { - lib: { - cancel: FunctionReference< - 'mutation', - 'internal', - { name: string }, - { - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - } - >; - cancelAll: FunctionReference< - 'mutation', - 'internal', - { sinceTs?: number }, - Array<{ - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - }> - >; - clearAll: FunctionReference<'mutation', 'internal', { before?: number }, null>; - getStatus: FunctionReference< - 'query', - 'internal', - { limit?: number; names?: Array }, - Array<{ - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - }> - >; - migrate: FunctionReference< - 'mutation', - 'internal', - { - batchSize?: number; - cursor?: string | null; - dryRun: boolean; - fnHandle: string; - name: string; - next?: Array<{ fnHandle: string; name: string }>; - oneBatchOnly?: boolean; - }, - { - batchSize?: number; - cursor?: string | null; - error?: string; - isDone: boolean; - latestEnd?: number; - latestStart: number; - name: string; - next?: Array; - processed: number; - state: 'inProgress' | 'success' | 'failed' | 'canceled' | 'unknown'; - } - >; - }; - }; + migrations: { + lib: { + cancel: FunctionReference< + "mutation", + "internal", + { name: string }, + { + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + } + >; + cancelAll: FunctionReference< + "mutation", + "internal", + { sinceTs?: number }, + Array<{ + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + }> + >; + clearAll: FunctionReference< + "mutation", + "internal", + { before?: number }, + null + >; + getStatus: FunctionReference< + "query", + "internal", + { limit?: number; names?: Array }, + Array<{ + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + }> + >; + migrate: FunctionReference< + "mutation", + "internal", + { + batchSize?: number; + cursor?: string | null; + dryRun: boolean; + fnHandle: string; + name: string; + next?: Array<{ fnHandle: string; name: string }>; + oneBatchOnly?: boolean; + }, + { + batchSize?: number; + cursor?: string | null; + error?: string; + isDone: boolean; + latestEnd?: number; + latestStart: number; + name: string; + next?: Array; + processed: number; + state: "inProgress" | "success" | "failed" | "canceled" | "unknown"; + } + >; + }; + }; }; diff --git a/apps/web/src/convex/_generated/api.js b/apps/web/src/convex/_generated/api.js index 24593c74..44bf9858 100644 --- a/apps/web/src/convex/_generated/api.js +++ b/apps/web/src/convex/_generated/api.js @@ -8,7 +8,7 @@ * @module */ -import { anyApi, componentsGeneric } from 'convex/server'; +import { anyApi, componentsGeneric } from "convex/server"; /** * A utility for referencing Convex functions in your app's API. diff --git a/apps/web/src/convex/_generated/dataModel.d.ts b/apps/web/src/convex/_generated/dataModel.d.ts index 5428df6f..f97fd194 100644 --- a/apps/web/src/convex/_generated/dataModel.d.ts +++ b/apps/web/src/convex/_generated/dataModel.d.ts @@ -9,13 +9,13 @@ */ import type { - DataModelFromSchemaDefinition, - DocumentByName, - TableNamesInDataModel, - SystemTableNames -} from 'convex/server'; -import type { GenericId } from 'convex/values'; -import schema from '../schema.js'; + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; /** * The names of all of your Convex tables. @@ -27,7 +27,10 @@ export type TableNames = TableNamesInDataModel; * * @typeParam TableName - A string literal type of the table name (like "users"). */ -export type Doc = DocumentByName; +export type Doc = DocumentByName< + DataModel, + TableName +>; /** * An identifier for a document in Convex. @@ -42,7 +45,8 @@ export type Doc = DocumentByName = GenericId; +export type Id = + GenericId; /** * A type describing your Convex data model. diff --git a/apps/web/src/convex/_generated/server.d.ts b/apps/web/src/convex/_generated/server.d.ts index 1cc047ef..bec05e68 100644 --- a/apps/web/src/convex/_generated/server.d.ts +++ b/apps/web/src/convex/_generated/server.d.ts @@ -9,17 +9,17 @@ */ import { - ActionBuilder, - HttpActionBuilder, - MutationBuilder, - QueryBuilder, - GenericActionCtx, - GenericMutationCtx, - GenericQueryCtx, - GenericDatabaseReader, - GenericDatabaseWriter -} from 'convex/server'; -import type { DataModel } from './dataModel.js'; + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; /** * Define a query in this Convex app's public API. @@ -29,7 +29,7 @@ import type { DataModel } from './dataModel.js'; * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ -export declare const query: QueryBuilder; +export declare const query: QueryBuilder; /** * Define a query that is only accessible from other Convex functions (but not from the client). @@ -39,7 +39,7 @@ export declare const query: QueryBuilder; * @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @returns The wrapped query. Include this as an `export` to name it and make it accessible. */ -export declare const internalQuery: QueryBuilder; +export declare const internalQuery: QueryBuilder; /** * Define a mutation in this Convex app's public API. @@ -49,7 +49,7 @@ export declare const internalQuery: QueryBuilder; * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ -export declare const mutation: MutationBuilder; +export declare const mutation: MutationBuilder; /** * Define a mutation that is only accessible from other Convex functions (but not from the client). @@ -59,7 +59,7 @@ export declare const mutation: MutationBuilder; * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. */ -export declare const internalMutation: MutationBuilder; +export declare const internalMutation: MutationBuilder; /** * Define an action in this Convex app's public API. @@ -72,7 +72,7 @@ export declare const internalMutation: MutationBuilder; * @param func - The action. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped action. Include this as an `export` to name it and make it accessible. */ -export declare const action: ActionBuilder; +export declare const action: ActionBuilder; /** * Define an action that is only accessible from other Convex functions (but not from the client). @@ -80,7 +80,7 @@ export declare const action: ActionBuilder; * @param func - The function. It receives an {@link ActionCtx} as its first argument. * @returns The wrapped function. Include this as an `export` to name it and make it accessible. */ -export declare const internalAction: ActionBuilder; +export declare const internalAction: ActionBuilder; /** * Define an HTTP action. diff --git a/apps/web/src/convex/_generated/server.js b/apps/web/src/convex/_generated/server.js index a18aa285..bf3d25ad 100644 --- a/apps/web/src/convex/_generated/server.js +++ b/apps/web/src/convex/_generated/server.js @@ -9,14 +9,14 @@ */ import { - actionGeneric, - httpActionGeneric, - queryGeneric, - mutationGeneric, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric -} from 'convex/server'; + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; /** * Define a query in this Convex app's public API. diff --git a/apps/web/src/convex/http.ts b/apps/web/src/convex/http.ts index 6d487884..c8171d54 100644 --- a/apps/web/src/convex/http.ts +++ b/apps/web/src/convex/http.ts @@ -10,6 +10,11 @@ import { httpAction, type ActionCtx } from './_generated/server.js'; import { AnalyticsEvents } from './analyticsEvents.js'; import { instances } from './apiHelpers.js'; import { withPrivateApiKey } from './privateWrappers.js'; +import { + INSTANCE_DISK_FULL_MESSAGE, + getInstanceErrorKind, + getUserFacingInstanceError +} from '../lib/instanceErrors'; import { WebUnhandledError, type WebError } from '../lib/result/errors'; type HttpFlowResult = Result; @@ -24,6 +29,7 @@ const http = httpRouter(); const corsAllowedMethods = 'GET, POST, OPTIONS'; const corsMaxAgeSeconds = 60 * 60 * 24; const defaultAllowedHeaders = 'Content-Type, Authorization, X-Requested-With'; +const localDevHosts = new Set(['localhost', '127.0.0.1']); const buildAllowedOrigins = (): Set => { const origins = (process.env.CLIENT_ORIGIN ?? '') @@ -32,7 +38,7 @@ const buildAllowedOrigins = (): Set => { .filter(Boolean); if (origins.length === 0) { - return new Set(['http://localhost:5173', 'http://localhost:5174', 'http://localhost:5175']); + return new Set(); } return new Set(origins); @@ -141,6 +147,7 @@ type BtcaStreamEvent = type InstanceRecord = { _id: Id<'instances'>; state: string; + errorKind?: 'disk_full' | 'generic'; serverUrl?: string | null; sandboxId?: string | null; }; @@ -167,6 +174,16 @@ function isOriginAllowed(origin: string | null): boolean { if (!origin) { return false; } + + if (allowedOrigins.size === 0) { + try { + const parsed = new URL(origin); + return localDevHosts.has(parsed.hostname); + } catch { + return false; + } + } + if (allowedOrigins.size === 0) { return false; } @@ -583,6 +600,16 @@ const chatStream = httpAction(async (ctx, request) => { controller.close(); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + if (getInstanceErrorKind(error) === 'disk_full') { + await ctx.runMutation( + instanceMutations.setError, + withPrivateApiKey({ + instanceId: instance._id, + errorKind: 'disk_full', + errorMessage: getUserFacingInstanceError(error, errorMessage) + }) + ); + } const streamDurationMs = Date.now() - streamStartedAt; await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { @@ -910,7 +937,14 @@ async function ensureServerUrlResult( sendEvent: (payload: StreamEventPayload) => void ): Promise> { if (instance.state === 'error') { - return Result.err(new WebUnhandledError({ message: 'Instance is in an error state' })); + return Result.err( + new WebUnhandledError({ + message: + instance.errorKind === 'disk_full' + ? INSTANCE_DISK_FULL_MESSAGE + : 'Instance is in an error state' + }) + ); } if (instance.state === 'provisioning' || instance.state === 'unprovisioned') { diff --git a/apps/web/src/convex/instances/actions.ts b/apps/web/src/convex/instances/actions.ts index e26616cc..c7f4357c 100644 --- a/apps/web/src/convex/instances/actions.ts +++ b/apps/web/src/convex/instances/actions.ts @@ -13,6 +13,7 @@ import { AnalyticsEvents } from '../analyticsEvents'; import { instances } from '../apiHelpers'; import { inspectGitHubConnectionForClerkUser } from '../githubAuth'; import { privateAction, withPrivateApiKey } from '../privateWrappers'; +import { getInstanceErrorKind, getUserFacingInstanceError } from '../../lib/instanceErrors'; import { WebAuthError, WebConfigMissingError, @@ -211,9 +212,16 @@ const getErrorDetails = (error: unknown) => { return { message: 'Unknown error' }; }; -const getErrorContext = (error: unknown) => { +const getErrorContext = (error: unknown): Record | undefined => { if (!error || typeof error !== 'object') return undefined; - return 'context' in error ? (error as { context?: Record }).context : undefined; + const directContext = + 'context' in error ? (error as { context?: Record }).context : undefined; + if (directContext) { + return directContext; + } + + const cause = 'cause' in error ? (error as { cause?: unknown }).cause : undefined; + return cause ? getErrorContext(cause) : undefined; }; const attachErrorContext = (error: unknown, context: Record) => { @@ -233,10 +241,10 @@ const throwInstanceError = (error: WebError): never => { }; const unwrapInstance = (result: InstanceActionResult): T => { - return Result.match(result, { - ok: (value) => value, - err: (error) => throwInstanceError(error) - }); + if (Result.isError(result)) { + throwInstanceError(result.error); + } + return (result as { value: T }).value; }; const withStep = async ( @@ -261,7 +269,9 @@ const formatUserMessage = (operation: string, step: string | undefined, detail?: ? 'Starting' : operation === 'update' ? 'Updating' - : 'Instance'; + : operation === 'migrate' + ? 'Migrating' + : 'Instance'; const stepLabel = step ? { load_resources: 'loading resources', @@ -273,7 +283,8 @@ const formatUserMessage = (operation: string, step: string | undefined, detail?: health_check: 'waiting for btca to respond', get_versions: 'checking package versions', update_packages: 'updating packages', - stop_sandbox: 'stopping the sandbox' + stop_sandbox: 'stopping the sandbox', + delete_old_sandbox: 'cleaning up the previous sandbox' }[step] : undefined; const base = `${actionLabel} failed${stepLabel ? ` while ${stepLabel}` : ''}.`; @@ -281,6 +292,30 @@ const formatUserMessage = (operation: string, step: string | undefined, detail?: return `${base}${trimmed ? ` ${trimmed}` : ''} Please retry.`; }; +const requiresSnapshotMigration = (instance: Doc<'instances'>) => + instance.snapshotName !== BTCA_SNAPSHOT_NAME; + +async function setInstanceError( + ctx: ActionCtx, + instanceId: Id<'instances'>, + error: unknown, + fallbackMessage: string +) { + const errorKind = getInstanceErrorKind(error); + const errorMessage = getUserFacingInstanceError(error, fallbackMessage); + + await ctx.runMutation( + instanceMutations.setError, + withPrivateApiKey({ + instanceId, + errorKind, + errorMessage + }) + ); + + return { errorKind, errorMessage }; +} + async function getResourceConfigs( ctx: ActionCtx, instanceId: Id<'instances'>, @@ -499,6 +534,64 @@ async function updatePackages(sandbox: Sandbox): Promise { await sandbox.process.executeCommand(`bun add -g ${BTCA_PACKAGE_NAME}`); } +async function createPreparedSandbox( + ctx: ActionCtx, + instanceId: Id<'instances'>, + instance: Doc<'instances'>, + includePrivate = true +): Promise<{ sandbox: Sandbox; versions: InstalledVersions }> { + requireEnv('OPENCODE_API_KEY'); + + let sandbox: Sandbox | null = null; + let step = 'load_resources'; + + try { + const resources = unwrapInstance( + await withStep(step, () => getResourceConfigs(ctx, instanceId, undefined, includePrivate)) + ); + const daytona = getDaytona(); + step = 'create_sandbox'; + const createdSandbox = unwrapInstance( + await withStep(step, () => + daytona.create({ + snapshot: BTCA_SNAPSHOT_NAME, + autoStopInterval: SANDBOX_IDLE_MINUTES, + envVars: { + NODE_ENV: 'production', + OPENCODE_API_KEY: requireEnv('OPENCODE_API_KEY') + }, + public: false + }) + ) + ); + sandbox = createdSandbox; + + step = 'upload_config'; + unwrapInstance( + await withStep(step, () => syncGitHubAuth(createdSandbox, instance.clerkId, resources)) + ); + step = 'upload_config'; + unwrapInstance(await withStep(step, () => uploadBtcaConfig(createdSandbox, resources))); + step = 'get_versions'; + const versions = unwrapInstance( + await withStep(step, () => getInstalledVersions(createdSandbox)) + ); + step = 'stop_sandbox'; + unwrapInstance(await withStep(step, () => stopSandboxIfRunning(createdSandbox))); + + return { sandbox: createdSandbox, versions }; + } catch (error) { + if (sandbox) { + try { + await sandbox.delete(60); + } catch { + // Ignore cleanup errors. + } + } + throw error; + } +} + async function fetchLatestVersion(packageName: string): Promise { try { const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`); @@ -534,50 +627,18 @@ export const provision = privateAction({ ); let sandbox: Sandbox | null = null; - let step = 'load_resources'; + let step = 'create_sandbox'; try { - const resources = unwrapInstance( - await withStep(step, () => getResourceConfigs(ctx, args.instanceId)) - ); - const daytona = getDaytona(); - step = 'create_sandbox'; - const createdSandbox = unwrapInstance( - await withStep(step, () => - daytona.create({ - snapshot: BTCA_SNAPSHOT_NAME, - autoStopInterval: SANDBOX_IDLE_MINUTES, - envVars: { - NODE_ENV: 'production', - OPENCODE_API_KEY: requireEnv('OPENCODE_API_KEY') - }, - public: false - }) - ) - ); - sandbox = createdSandbox; - - step = 'upload_config'; - unwrapInstance( - await withStep(step, () => syncGitHubAuth(createdSandbox, instance.clerkId, resources)) - ); - step = 'upload_config'; - unwrapInstance(await withStep(step, () => uploadBtcaConfig(createdSandbox, resources))); - - step = 'start_btca'; - unwrapInstance(await withStep(step, () => startBtcaServer(createdSandbox))); - - step = 'get_versions'; - const versions = unwrapInstance( - await withStep(step, () => getInstalledVersions(createdSandbox)) - ); - step = 'stop_sandbox'; - unwrapInstance(await withStep(step, () => stopSandboxIfRunning(createdSandbox))); + const preparedSandbox = await createPreparedSandbox(ctx, args.instanceId, instance); + sandbox = preparedSandbox.sandbox; + const versions = preparedSandbox.versions; await ctx.runMutation( instanceMutations.setProvisioned, withPrivateApiKey({ instanceId: args.instanceId, - sandboxId: createdSandbox.id, + sandboxId: sandbox.id, + snapshotName: BTCA_SNAPSHOT_NAME, btcaVersion: versions.btcaVersion }) ); @@ -611,14 +672,6 @@ export const provision = privateAction({ return { sandboxId: sandbox.id }; } catch (error) { - if (sandbox) { - try { - await sandbox.delete(); - } catch { - // Ignore cleanup errors - } - } - const errorDetails = getErrorDetails(error); const context = getErrorContext(error); const contextStep = typeof context?.step === 'string' ? context.step : step; @@ -650,14 +703,8 @@ export const provision = privateAction({ } }); - await ctx.runMutation( - instanceMutations.setError, - withPrivateApiKey({ - instanceId: args.instanceId, - errorMessage: message - }) - ); - throw new WebUnhandledError({ message }); + const { errorMessage } = await setInstanceError(ctx, args.instanceId, error, message); + throw new WebUnhandledError({ message: errorMessage }); } } }); @@ -831,6 +878,7 @@ async function createSandboxFromScratch( withPrivateApiKey({ instanceId, sandboxId: sandbox.id, + snapshotName: BTCA_SNAPSHOT_NAME, btcaVersion: versions.btcaVersion }) ); @@ -946,11 +994,8 @@ async function wakeInstanceInternal( context }); - await ctx.runMutation( - instanceMutations.setError, - withPrivateApiKey({ instanceId, errorMessage: message }) - ); - throw new WebUnhandledError({ message }); + const { errorMessage } = await setInstanceError(ctx, instanceId, error, message); + throw new WebUnhandledError({ message: errorMessage }); } } @@ -986,11 +1031,8 @@ async function stopInstanceInternal( return { stopped: true }; } catch (error) { const message = getErrorMessage(error); - await ctx.runMutation( - instanceMutations.setError, - withPrivateApiKey({ instanceId, errorMessage: message }) - ); - throw new WebUnhandledError({ message }); + const { errorMessage } = await setInstanceError(ctx, instanceId, error, message); + throw new WebUnhandledError({ message: errorMessage }); } } @@ -1075,12 +1117,124 @@ async function updateInstanceInternal( return { updated: true }; } catch (error) { const message = getErrorMessage(error); + const { errorMessage } = await setInstanceError(ctx, instanceId, error, message); + throw new WebUnhandledError({ message: errorMessage }); + } +} + +export const migrate = privateAction({ + args: instanceArgs, + returns: v.object({ sandboxId: v.string() }), + handler: async (ctx, args) => { + const instance = await requireInstance(ctx, args.instanceId); + const previousSandboxId = instance.sandboxId; + const migrationStartedAt = Date.now(); + let sandbox: Sandbox | null = null; + let step = 'create_sandbox'; + + await ctx.runMutation( + instanceMutations.updateState, + withPrivateApiKey({ instanceId: args.instanceId, state: 'provisioning' }) + ); await ctx.runMutation( - instanceMutations.setError, - withPrivateApiKey({ instanceId, errorMessage: message }) + instanceMutations.setServerUrl, + withPrivateApiKey({ instanceId: args.instanceId, serverUrl: '' }) + ); + await ctx.runMutation( + instanceMutations.clearError, + withPrivateApiKey({ instanceId: args.instanceId }) ); - throw new WebUnhandledError({ message }); + + try { + const preparedSandbox = await createPreparedSandbox(ctx, args.instanceId, instance); + sandbox = preparedSandbox.sandbox; + const versions = preparedSandbox.versions; + + await ctx.runMutation( + instanceMutations.setProvisioned, + withPrivateApiKey({ + instanceId: args.instanceId, + sandboxId: sandbox.id, + snapshotName: BTCA_SNAPSHOT_NAME, + btcaVersion: versions.btcaVersion + }) + ); + await ctx.runMutation( + instanceMutations.touchActivity, + withPrivateApiKey({ instanceId: args.instanceId }) + ); + await ctx.scheduler.runAfter( + 0, + instances.actions.update, + withPrivateApiKey({ instanceId: args.instanceId }) + ); + + if (previousSandboxId) { + step = 'delete_old_sandbox'; + try { + const previousSandbox = await getDaytona().get(previousSandboxId); + await previousSandbox.delete(60); + } catch { + // Ignore cleanup errors for the previous sandbox. + } + } + + console.log('Sandbox migration completed', { + instanceId: args.instanceId, + previousSandboxId, + newSandboxId: sandbox.id, + durationMs: Date.now() - migrationStartedAt + }); + + return { sandboxId: sandbox.id }; + } catch (error) { + const message = formatUserMessage('migrate', step, getErrorMessage(error)); + console.error('Sandbox migration failed', { + instanceId: args.instanceId, + previousSandboxId, + newSandboxId: sandbox?.id, + step, + durationMs: Date.now() - migrationStartedAt, + error: getErrorDetails(error), + context: getErrorContext(error) + }); + const { errorMessage } = await setInstanceError(ctx, args.instanceId, error, message); + throw new WebUnhandledError({ message: errorMessage }); + } + } +}); + +async function maybeScheduleSnapshotMigration( + ctx: ActionCtx, + instance: Doc<'instances'> +): Promise { + if (!requiresSnapshotMigration(instance)) { + return false; } + + if (instance.state === 'unprovisioned' || instance.state === 'provisioning') { + return true; + } + + await ctx.runMutation( + instanceMutations.updateState, + withPrivateApiKey({ instanceId: instance._id, state: 'provisioning' }) + ); + await ctx.runMutation( + instanceMutations.setServerUrl, + withPrivateApiKey({ instanceId: instance._id, serverUrl: '' }) + ); + await ctx.runMutation( + instanceMutations.clearError, + withPrivateApiKey({ instanceId: instance._id }) + ); + await ctx.scheduler.runAfter( + 0, + instances.actions.migrate, + withPrivateApiKey({ instanceId: instance._id }) + ); + + return true; } export const wakeMyInstance = action({ @@ -1117,8 +1271,11 @@ export const ensureInstanceExists = action({ const existing = await ctx.runQuery(instanceQueries.getByClerkId, {}); if (existing) { + const migrationScheduled = await maybeScheduleSnapshotMigration(ctx, existing); const isProvisioning = - existing.state === 'unprovisioned' || existing.state === 'provisioning'; + migrationScheduled || + existing.state === 'unprovisioned' || + existing.state === 'provisioning'; return { instanceId: existing._id, status: isProvisioning ? 'provisioning' : 'exists' @@ -1153,8 +1310,11 @@ export const ensureInstanceExistsPrivate = privateAction({ }); if (existing) { + const migrationScheduled = await maybeScheduleSnapshotMigration(ctx, existing); const isProvisioning = - existing.state === 'unprovisioned' || existing.state === 'provisioning'; + migrationScheduled || + existing.state === 'unprovisioned' || + existing.state === 'provisioning'; return { instanceId: existing._id, status: isProvisioning ? 'provisioning' : 'exists' diff --git a/apps/web/src/convex/instances/mutations.ts b/apps/web/src/convex/instances/mutations.ts index 20806fb7..71ec3fa7 100644 --- a/apps/web/src/convex/instances/mutations.ts +++ b/apps/web/src/convex/instances/mutations.ts @@ -61,6 +61,7 @@ export const setProvisioned = privateMutation({ args: { instanceId: v.id('instances'), sandboxId: v.string(), + snapshotName: v.string(), btcaVersion: v.optional(v.string()), storageUsedBytes: v.optional(v.number()) }, @@ -68,22 +69,36 @@ export const setProvisioned = privateMutation({ handler: async (ctx, args) => { const patch: { sandboxId: string; + snapshotName: string; state: 'stopped'; provisionedAt: number; + serverUrl: string; + errorKind: undefined; + errorMessage: undefined; + storageUsedBytes: number; btcaVersion?: string; - storageUsedBytes?: number; } = { sandboxId: args.sandboxId, + snapshotName: args.snapshotName, state: 'stopped', - provisionedAt: Date.now() + provisionedAt: Date.now(), + serverUrl: '', + errorKind: undefined, + errorMessage: undefined, + storageUsedBytes: args.storageUsedBytes ?? 0 }; if (args.btcaVersion !== undefined) { patch.btcaVersion = args.btcaVersion; } - if (args.storageUsedBytes !== undefined) { - patch.storageUsedBytes = args.storageUsedBytes; + const cachedResources = await ctx.db + .query('cachedResources') + .withIndex('by_instance', (q) => q.eq('instanceId', args.instanceId)) + .collect(); + + for (const resource of cachedResources) { + await ctx.db.delete(resource._id); } await ctx.db.patch(args.instanceId, patch); @@ -106,6 +121,7 @@ export const setServerUrl = privateMutation({ export const setError = privateMutation({ args: { instanceId: v.id('instances'), + errorKind: v.optional(v.union(v.literal('disk_full'), v.literal('generic'))), errorMessage: v.string() }, returns: v.null(), @@ -115,6 +131,7 @@ export const setError = privateMutation({ await ctx.db.patch(args.instanceId, { state: 'error', + errorKind: args.errorKind ?? 'generic', errorMessage: args.errorMessage }); @@ -137,7 +154,10 @@ export const clearError = privateMutation({ args: { instanceId: v.id('instances') }, returns: v.null(), handler: async (ctx, args) => { - await ctx.db.patch(args.instanceId, { errorMessage: undefined }); + await ctx.db.patch(args.instanceId, { + errorKind: undefined, + errorMessage: undefined + }); return null; } }); diff --git a/apps/web/src/convex/instances/queries.ts b/apps/web/src/convex/instances/queries.ts index 0b6de6a4..c0e5fd75 100644 --- a/apps/web/src/convex/instances/queries.ts +++ b/apps/web/src/convex/instances/queries.ts @@ -1,5 +1,6 @@ import { v } from 'convex/values'; import { Result } from 'better-result'; +import { BTCA_SNAPSHOT_NAME } from 'btca-sandbox/shared'; import { internalQuery, query } from '../_generated/server'; import { requireInstanceOwnershipResult, unwrapAuthResult } from '../authHelpers'; @@ -11,6 +12,7 @@ const instanceValidator = v.object({ _creationTime: v.number(), clerkId: v.string(), sandboxId: v.optional(v.string()), + snapshotName: v.optional(v.string()), state: v.union( v.literal('unprovisioned'), v.literal('provisioning'), @@ -22,6 +24,7 @@ const instanceValidator = v.object({ v.literal('error') ), serverUrl: v.optional(v.string()), + errorKind: v.optional(v.union(v.literal('disk_full'), v.literal('generic'))), errorMessage: v.optional(v.string()), btcaVersion: v.optional(v.string()), opencodeVersion: v.optional(v.string()), @@ -173,7 +176,9 @@ export const getStatus = query({ v.null(), v.object({ instance: instanceValidator, - cachedResources: v.array(cachedResourceValidator) + cachedResources: v.array(cachedResourceValidator), + expectedSnapshotName: v.string(), + migrationNeeded: v.boolean() }) ), handler: async (ctx) => { @@ -200,7 +205,9 @@ export const getStatus = query({ return { instance, - cachedResources: cachedResources.map((resource) => normalizeCachedResource(resource)) + cachedResources: cachedResources.map((resource) => normalizeCachedResource(resource)), + expectedSnapshotName: BTCA_SNAPSHOT_NAME, + migrationNeeded: instance.snapshotName !== BTCA_SNAPSHOT_NAME }; } }); diff --git a/apps/web/src/convex/mcp.ts b/apps/web/src/convex/mcp.ts index 1dfc2813..a0f0189b 100644 --- a/apps/web/src/convex/mcp.ts +++ b/apps/web/src/convex/mcp.ts @@ -11,6 +11,11 @@ import { instances } from './apiHelpers'; import type { ApiKeyValidationResult } from './clerkApiKeys'; import { getAvailableMcpResourceNames, toMcpVisibleResources } from './mcp/resourceContract.ts'; import { withPrivateApiKey } from './privateWrappers'; +import { + INSTANCE_DISK_FULL_APP_MESSAGE, + getInstanceErrorKind, + getUserFacingInstanceError +} from '../lib/instanceErrors'; import { toWebError, type WebError } from '../lib/result/errors'; const instanceActions = instances.actions; @@ -221,7 +226,13 @@ export const ask = action({ ...projectProperties, instanceState: instance.state }); - return { ok: false as const, error: 'Instance is in an error state' }; + return { + ok: false as const, + error: + instance.errorKind === 'disk_full' + ? INSTANCE_DISK_FULL_APP_MESSAGE + : 'Instance is in an error state' + }; } if (instance.state === 'provisioning' || instance.state === 'unprovisioned') { @@ -283,6 +294,16 @@ export const ask = action({ if (!response.ok) { const errorText = await response.text(); + if (getInstanceErrorKind(errorText) === 'disk_full') { + await ctx.runMutation( + instanceMutations.setError, + withPrivateApiKey({ + instanceId, + errorKind: 'disk_full', + errorMessage: getUserFacingInstanceError(errorText, errorText) + }) + ); + } await trackAskFailure(errorText || `Server error: ${response.status}`, { ...projectProperties, status: response.status, diff --git a/apps/web/src/convex/schema.ts b/apps/web/src/convex/schema.ts index 40c237e3..88701812 100644 --- a/apps/web/src/convex/schema.ts +++ b/apps/web/src/convex/schema.ts @@ -39,6 +39,7 @@ export default defineSchema({ instances: defineTable({ clerkId: v.string(), sandboxId: v.optional(v.string()), + snapshotName: v.optional(v.string()), state: v.union( v.literal('unprovisioned'), v.literal('provisioning'), @@ -50,6 +51,7 @@ export default defineSchema({ v.literal('error') ), serverUrl: v.optional(v.string()), + errorKind: v.optional(v.union(v.literal('disk_full'), v.literal('generic'))), errorMessage: v.optional(v.string()), btcaVersion: v.optional(v.string()), opencodeVersion: v.optional(v.string()), diff --git a/apps/web/src/lib/components/InstanceCard.svelte b/apps/web/src/lib/components/InstanceCard.svelte index 14746017..e7e36561 100644 --- a/apps/web/src/lib/components/InstanceCard.svelte +++ b/apps/web/src/lib/components/InstanceCard.svelte @@ -12,15 +12,13 @@ Square } from '@lucide/svelte'; import { getInstanceStore } from '$lib/stores/instance.svelte'; + import { INSTANCE_DISK_FULL_MESSAGE } from '$lib/instanceErrors'; type InstanceAction = 'wake' | 'stop' | 'update' | 'reset'; const instanceStore = getInstanceStore(); let pendingAction = $state(null); let isExpanded = $state(false); - const storageLimitBytes = 10 * 1024 * 1024 * 1024; - const byteFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }); - const stateMeta: Record< string, { @@ -87,6 +85,14 @@ const stateInfo = $derived.by(() => { const state = instanceStore.state ?? 'unknown'; + if (state === 'error' && instanceStore.errorKind === 'disk_full') { + return { + label: 'Cache full', + description: 'Your instance cache is full. Reset it to keep going.', + tone: 'error' as const, + icon: AlertTriangle + }; + } return ( stateMeta[state] ?? { label: 'Checking', @@ -123,9 +129,7 @@ const canStop = $derived.by(() => instanceStore.state === 'running'); const canUpdate = $derived.by(() => ['running', 'stopped'].includes(instanceStore.state ?? '')); const canReset = $derived.by(() => instanceStore.state === 'error'); - - const storageUsed = $derived.by(() => instanceStore.storageUsedBytes ?? 0); - const storagePercent = $derived.by(() => Math.min((storageUsed / storageLimitBytes) * 100, 100)); + const isDiskFullError = $derived.by(() => instanceStore.errorKind === 'disk_full'); const displayedResources = $derived.by(() => instanceStore.cachedResources.slice(0, 4)); const getCachedResourceMeta = (resource: { @@ -153,23 +157,12 @@ const errorText = $derived.by( () => + (instanceStore.errorKind === 'disk_full' ? INSTANCE_DISK_FULL_MESSAGE : null) ?? instanceStore.instance?.errorMessage ?? instanceStore.error ?? 'Instance failed to start. Please retry.' ); - function formatBytes(bytes: number) { - if (!bytes) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let value = bytes; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex += 1; - } - return `${byteFormatter.format(value)} ${units[unitIndex]}`; - } - function formatDateTime(timestamp?: number | null) { if (!timestamp) return 'Unknown'; return new Date(timestamp).toLocaleString(undefined, { @@ -302,6 +295,13 @@

Instance needs attention

{errorText}

+

+ {#if isDiskFullError} + Reset your instance to clear cached repos and keep going. + {:else} + Reset your instance to rebuild it from scratch. + {/if} +

@@ -403,58 +403,35 @@ -
-
-

Versions

-
-
- btca -
- {instanceStore.btcaVersion ?? 'Unknown'} - {#if instanceStore.btcaUpdateAvailable} - - → {instanceStore.latestBtcaVersion} - - {/if} -
-
-
- opencode -
- {instanceStore.opencodeVersion ?? 'Unknown'} - {#if instanceStore.opencodeUpdateAvailable} - - → {instanceStore.latestOpencodeVersion} - - {/if} -
+
+

Versions

+
+
+ btca +
+ {instanceStore.btcaVersion ?? 'Unknown'} + {#if instanceStore.btcaUpdateAvailable} + + → {instanceStore.latestBtcaVersion} + + {/if}
-

- Last check: {formatDateTime(instanceStore.instance?.lastVersionCheck)} -

-
- -
-

Storage

-
- Used - - {storageUsed - ? `${formatBytes(storageUsed)} of ${formatBytes(storageLimitBytes)}` - : 'Usage pending'} - -
-
-
+
+ opencode +
+ {instanceStore.opencodeVersion ?? 'Unknown'} + {#if instanceStore.opencodeUpdateAvailable} + + → {instanceStore.latestOpencodeVersion} + + {/if} +
-

Storage updates after provisioning completes.

+

+ Last check: {formatDateTime(instanceStore.instance?.lastVersionCheck)} +

diff --git a/apps/web/src/lib/components/InstanceStatus.svelte b/apps/web/src/lib/components/InstanceStatus.svelte index 6de47c66..01c7b42c 100644 --- a/apps/web/src/lib/components/InstanceStatus.svelte +++ b/apps/web/src/lib/components/InstanceStatus.svelte @@ -10,6 +10,7 @@ Square } from '@lucide/svelte'; import { getInstanceStore } from '$lib/stores/instance.svelte'; + import { INSTANCE_DISK_FULL_MESSAGE } from '$lib/instanceErrors'; type InstanceAction = 'wake' | 'stop' | 'update' | 'reset'; @@ -83,6 +84,14 @@ const stateInfo = $derived.by(() => { const state = instanceStore.state ?? 'unknown'; + if (state === 'error' && instanceStore.errorKind === 'disk_full') { + return { + label: 'Cache full', + description: 'Your instance cache is full. Reset it to continue.', + tone: 'error' as const, + icon: AlertTriangle + }; + } return ( stateMeta[state] ?? { label: 'Checking', @@ -120,6 +129,7 @@ const canStop = $derived.by(() => instanceStore.state === 'running'); const canUpdate = $derived.by(() => ['running', 'stopped'].includes(instanceStore.state ?? '')); const canReset = $derived.by(() => instanceStore.state === 'error'); + const isDiskFullError = $derived.by(() => instanceStore.errorKind === 'disk_full'); const updateSummary = $derived.by(() => { const parts: string[] = []; @@ -245,7 +255,11 @@
Instance needs attention

- Try resetting your instance. This will fully restart it from scratch. + {#if isDiskFullError} + {INSTANCE_DISK_FULL_MESSAGE} + {:else} + Try resetting your instance. This will fully restart it from scratch. + {/if}

{/if} {/if} diff --git a/apps/web/src/lib/components/ProvisioningModal.svelte b/apps/web/src/lib/components/ProvisioningModal.svelte index 47df7080..2eb19977 100644 --- a/apps/web/src/lib/components/ProvisioningModal.svelte +++ b/apps/web/src/lib/components/ProvisioningModal.svelte @@ -14,11 +14,13 @@ const isBootstrapping = $derived(instanceStore.isBootstrapping); const hasInstance = $derived(Boolean(instanceStore.instance)); const instanceState = $derived(instanceStore.state ?? ''); + const hasProvisionedAt = $derived(Boolean(instanceStore.instance?.provisionedAt)); const isVisible = $derived.by(() => { if (instanceStore.isLoading) return false; if (isBootstrapping) return true; if (!hasInstance) return false; + if (hasProvisionedAt) return false; return instanceState === 'unprovisioned' || instanceState === 'provisioning'; }); @@ -36,7 +38,6 @@ const hasInstanceRecord = $derived(hasInstance); const hasSandbox = $derived(Boolean(instanceStore.instance?.sandboxId)); - const hasProvisionedAt = $derived(Boolean(instanceStore.instance?.provisionedAt)); const hasPackages = $derived(Boolean(instanceStore.btcaVersion || instanceStore.opencodeVersion)); const hasServer = $derived(Boolean(instanceStore.instance?.serverUrl)); diff --git a/apps/web/src/lib/instanceErrors.ts b/apps/web/src/lib/instanceErrors.ts new file mode 100644 index 00000000..44dff3dd --- /dev/null +++ b/apps/web/src/lib/instanceErrors.ts @@ -0,0 +1,59 @@ +export type InstanceErrorKind = 'disk_full' | 'generic'; + +export const INSTANCE_DISK_FULL_MESSAGE = 'Your instance cache is full. Reset it to continue.'; +export const INSTANCE_DISK_FULL_APP_MESSAGE = + 'Your instance cache is full. Reset it in the web app to continue.'; + +const diskFullPatterns = [ + /enospc/i, + /no space left on device/i, + /disk quota exceeded/i, + /quota exceeded/i +]; + +const collectErrorParts = (value: unknown, seen = new Set()): string[] => { + if (value == null || seen.has(value)) return []; + + if (typeof value === 'string') { + return [value]; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return [String(value)]; + } + + if (typeof value !== 'object') { + return []; + } + + seen.add(value); + + if (value instanceof Error) { + return [ + value.message, + value.stack ?? '', + ...collectErrorParts((value as Error & { cause?: unknown }).cause, seen) + ]; + } + + const record = value as Record; + const parts = Object.values(record).flatMap((entry) => collectErrorParts(entry, seen)); + + try { + parts.push(JSON.stringify(record)); + } catch { + // Ignore circular or non-serializable values. + } + + return parts; +}; + +export const getInstanceErrorKind = (value: unknown): InstanceErrorKind => { + const haystack = collectErrorParts(value).join('\n'); + return diskFullPatterns.some((pattern) => pattern.test(haystack)) ? 'disk_full' : 'generic'; +}; + +export const isDiskFullError = (value: unknown) => getInstanceErrorKind(value) === 'disk_full'; + +export const getUserFacingInstanceError = (value: unknown, fallback: string) => + getInstanceErrorKind(value) === 'disk_full' ? INSTANCE_DISK_FULL_MESSAGE : fallback; diff --git a/apps/web/src/lib/stores/instance.svelte.ts b/apps/web/src/lib/stores/instance.svelte.ts index 9b03fe78..b104856d 100644 --- a/apps/web/src/lib/stores/instance.svelte.ts +++ b/apps/web/src/lib/stores/instance.svelte.ts @@ -9,6 +9,8 @@ import { WebValidationError } from '../result/errors'; type InstanceStatus = { instance: Doc<'instances'>; cachedResources: Doc<'cachedResources'>[]; + expectedSnapshotName: string; + migrationNeeded: boolean; } | null; type InstanceActionResponse = { @@ -43,10 +45,22 @@ class InstanceStore { return this.status?.cachedResources ?? []; } + get expectedSnapshotName() { + return this.status?.expectedSnapshotName ?? null; + } + + get migrationNeeded() { + return this.status?.migrationNeeded ?? false; + } + get state() { return this.status?.instance.state ?? null; } + get errorKind() { + return this.status?.instance.errorKind ?? null; + } + get btcaVersion() { return this.status?.instance.btcaVersion ?? null; } @@ -100,7 +114,7 @@ class InstanceStore { } get needsBootstrap() { - return !this._query.isLoading && !this.instance && !this._hasBootstrapped; + return !this._query.isLoading && !this._hasBootstrapped; } async ensureExists(): Promise { diff --git a/apps/web/src/routes/app/chat/[id]/+page.svelte b/apps/web/src/routes/app/chat/[id]/+page.svelte index 430fe5cd..89641ac2 100644 --- a/apps/web/src/routes/app/chat/[id]/+page.svelte +++ b/apps/web/src/routes/app/chat/[id]/+page.svelte @@ -13,6 +13,7 @@ import { threadPreloadStore } from '$lib/stores/threadPreload.svelte'; import { trackEvent, ClientAnalyticsEvents } from '$lib/stores/analytics.svelte'; import { SUPPORT_URL } from '$lib/billing/plans'; + import { INSTANCE_DISK_FULL_MESSAGE } from '$lib/instanceErrors'; import type { BtcaChunk, CancelState } from '$lib/types'; import { api } from '../../../../convex/_generated/api'; import type { Id } from '../../../../convex/_generated/dataModel'; @@ -56,6 +57,7 @@ let streamStatus = $state(null); let cancelState = $state('none'); let inputValue = $state(''); + let isResettingInstance = $state(false); let chatMessagesRef = $state<{ scrollToBottom: (behavior?: ScrollBehavior) => void } | null>( null ); @@ -85,6 +87,9 @@ !billingStore.isOverLimit && hasUsableInstance ); + const showDiskFullBanner = $derived.by( + () => instanceStore.state === 'error' && instanceStore.errorKind === 'disk_full' + ); // Only show local streaming UI if it's for THIS thread const isStreamingThisThread = $derived(isStreaming && streamingForThreadId === threadId); @@ -231,6 +236,19 @@ } } + async function resetInstanceFromChat() { + if (isResettingInstance) return; + isResettingInstance = true; + try { + const result = await instanceStore.reset(); + if (result?.error) { + alert(result.error); + } + } finally { + isResettingInstance = false; + } + } + async function retryMessage(message: import('$lib/types').Message & { role: 'user' }) { if (!threadId || isStreaming) return; @@ -296,6 +314,13 @@ } return; } + if (instanceStore.state === 'error' && instanceStore.errorKind === 'disk_full') { + const resetNow = confirm(`${INSTANCE_DISK_FULL_MESSAGE} Reset it now?`); + if (resetNow) { + await resetInstanceFromChat(); + } + return; + } alert('Instance unavailable. Try again shortly.'); return; } @@ -644,6 +669,9 @@ if (instanceStore.state === 'stopped') { return 'Instance is stopped. Wake it to chat.'; } + if (instanceStore.state === 'error' && instanceStore.errorKind === 'disk_full') { + return 'Instance cache full. Reset it to continue.'; + } return 'Instance unavailable. Try again soon.'; } if (isStreaming && cancelState === 'pending') return 'Press Escape again to cancel'; @@ -786,6 +814,30 @@
{/if} + {#if showDiskFullBanner} +
+
+
+

Instance cache full

+

{INSTANCE_DISK_FULL_MESSAGE}

+
+ +
+
+ {/if} + {#if threadResources.length > 0}
Active: From 2fac28f3e03588ef5caca1df0f39622674e90f8e Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Thu, 5 Mar 2026 17:56:12 -0800 Subject: [PATCH 03/11] model picker --- PLAN.md | 433 ++++++++++++++++ apps/web/autumn.config.ts | 14 +- apps/web/src/convex/http.ts | 129 ++++- apps/web/src/convex/instances/actions.ts | 109 +++- apps/web/src/convex/mcp.ts | 7 +- apps/web/src/convex/mcpInternal.ts | 7 + apps/web/src/convex/messages.ts | 20 +- apps/web/src/convex/projects.ts | 9 + apps/web/src/convex/schema.ts | 11 + apps/web/src/convex/threads.ts | 11 + apps/web/src/convex/usage.ts | 467 ++++++++++++------ apps/web/src/lib/billing/aiBudget.ts | 43 ++ apps/web/src/lib/billing/plans.ts | 22 +- apps/web/src/lib/billing/types.ts | 4 +- .../src/lib/components/ChatMessages.svelte | 58 ++- .../lib/components/ProjectModelPicker.svelte | 194 ++++++++ .../lib/components/ProvisioningModal.svelte | 6 +- .../components/pricing/PricingPlans.svelte | 10 +- apps/web/src/lib/models/webSandboxModels.ts | 58 +++ apps/web/src/lib/stores/billing.svelte.ts | 6 +- apps/web/src/lib/stores/instance.svelte.ts | 38 +- apps/web/src/lib/types/index.ts | 11 + .../web/src/routes/app/chat/[id]/+page.svelte | 69 ++- apps/web/src/routes/app/settings/+page.svelte | 39 +- .../routes/app/settings/billing/+page.svelte | 41 +- .../app/settings/questions/+page.svelte | 4 +- .../app/settings/resources/+page.svelte | 6 +- apps/web/src/routes/web/+page.svelte | 4 +- 28 files changed, 1580 insertions(+), 250 deletions(-) create mode 100644 PLAN.md create mode 100644 apps/web/src/lib/billing/aiBudget.ts create mode 100644 apps/web/src/lib/components/ProjectModelPicker.svelte create mode 100644 apps/web/src/lib/models/webSandboxModels.ts diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..0632ff03 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,433 @@ +# Web Model Picker + AI Budget Plan + +## Goal + +Add a seamless model picker to the web app for these `opencode` models: + +- `minimax-m2.5` +- `claude-haiku-4-5` +- `gpt-5.4` + +Users should be able to switch models per project. Billing should use one shared monthly AI budget instead of raw token buckets. The billing UI should show only a percentage bar, not dollar amounts. + +## Product Decisions + +- Scope model choice to `projects.model`, not user-global state. +- Keep provider fixed to `opencode` for v1. +- Replace `tokens_in`, `tokens_out`, and `sandbox_hours` plan enforcement with one Autumn feature: `ai_budget`. +- Ignore sandbox runtime cost for usage enforcement in v1. +- Prefer a local pricing snapshot sourced from `models.dev`, not live runtime fetches for billing-critical math. +- Billing UI shows a usage bar and percentage only. +- Model picker copy should describe relative usage, not price. + +## Pricing Snapshot + +Source: + +- `https://models.dev/` +- `https://models.dev/api.json` + +Snapshot date: `2026-03-05` + +### Supported Web Models + +| Model | Relative tier | Input / 1M | Output / 1M | Cache read / 1M | Cache write / 1M | +| --- | --- | ---: | ---: | ---: | ---: | +| `minimax-m2.5` | low | `$0.30` | `$1.20` | `$0.06` | n/a | +| `claude-haiku-4-5` | medium | `$1.00` | `$5.00` | `$0.10` | `$1.25` | +| `gpt-5.4` | high | `$2.50` | `$15.00` | `$0.25` | n/a | + +## Existing Code Anchors + +### Data / project model state + +- `apps/web/src/convex/schema.ts` +- `apps/web/src/convex/projects.ts` +- `apps/web/src/lib/stores/project.svelte.ts` + +### Sandbox config generation + +- `apps/web/src/convex/instances/actions.ts` + +### Billing / Autumn + +- `apps/web/autumn.config.ts` +- `apps/web/src/convex/usage.ts` +- `apps/web/src/lib/billing/plans.ts` +- `apps/web/src/lib/components/pricing/PricingPlans.svelte` +- `apps/web/src/routes/app/settings/+page.svelte` + +### Chat flow / usage finalization + +- `apps/web/src/convex/http.ts` +- `apps/server/src/stream/service.ts` + +## High-Level Rollout + +### Phase 1: Shared model + pricing config + +- [ ] Add one web-facing model catalog for the 3 supported models. +- [ ] Add one pricing snapshot module for budget calculations. +- [ ] Keep pricing math internal and stable; do not show raw dollar figures in the UI. + +Suggested file: + +- `apps/web/src/lib/models/webSandboxModels.ts` + +Suggested shape: + +```ts +export const WEB_SANDBOX_MODELS = [ + { + id: 'minimax-m2.5', + label: 'MiniMax M2.5', + provider: 'opencode', + tier: 'low', + description: 'Lowest usage', + ratesUsdPerMTokens: { + input: 0.3, + output: 1.2, + cacheRead: 0.06 + } + }, + { + id: 'claude-haiku-4-5', + label: 'Claude Haiku 4.5', + provider: 'opencode', + tier: 'medium', + description: 'Balanced', + ratesUsdPerMTokens: { + input: 1, + output: 5, + cacheRead: 0.1, + cacheWrite: 1.25 + } + }, + { + id: 'gpt-5.4', + label: 'GPT-5.4', + provider: 'opencode', + tier: 'high', + description: 'Highest usage', + ratesUsdPerMTokens: { + input: 2.5, + output: 15, + cacheRead: 0.25 + } + } +] as const; +``` + +Notes: + +- Use this as the single source for picker options. +- Use the same module for billing math and UX labels. +- For v1, prefer explicit constants over querying `models.dev` at request time. + +### Phase 2: AI budget in Autumn + +- [ ] Replace Autumn features `tokens_in`, `tokens_out`, and `sandbox_hours` for plan enforcement with a single `ai_budget` feature. +- [ ] Keep `chat_messages` for free plan if desired. +- [ ] Set Pro included usage to a `$5/month` equivalent using integer-safe units. +- [ ] Remove sandbox-hour enforcement from usage checks and UI summaries. + +Recommended implementation detail: + +- Store budget in integer micros or millicents. +- Do not store floats as the tracked unit. + +Suggested approach: + +```ts +const USD_MICROS_PER_USD = 1_000_000; +const PRO_AI_BUDGET_USD = 5; +const PRO_AI_BUDGET_MICROS = PRO_AI_BUDGET_USD * USD_MICROS_PER_USD; +``` + +Suggested Autumn change: + +```ts +export const aiBudget = feature({ + id: 'ai_budget', + name: 'AI Budget', + type: 'single_use' +}); + +export const btcaPro = product({ + id: 'btca_pro', + name: 'Pro Plan', + items: [ + priceItem({ + price: 8, + interval: 'month' + }), + featureItem({ + feature_id: aiBudget.id, + included_usage: 5_000_000, + interval: 'month' + }) + ] +}); +``` + +Files to touch: + +- `apps/web/autumn.config.ts` +- `apps/web/src/lib/billing/plans.ts` +- `apps/web/src/convex/usage.ts` + +### Phase 3: Budget math + enforcement + +- [ ] Add helpers to convert token usage into AI-budget usage units. +- [ ] Use selected project model to estimate budget required before a request starts. +- [ ] Charge actual budget usage after the response finishes. +- [ ] Keep free plan logic separate from Pro budget logic. + +Suggested helpers: + +```ts +type Rates = { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; +}; + +const toUsdMicros = (usd?: number) => + usd == null ? 0 : Math.round(usd * 1_000_000); + +const costPartMicros = (tokens: number, usdPerMTokens?: number) => + usdPerMTokens == null ? 0 : Math.round((tokens / 1_000_000) * toUsdMicros(usdPerMTokens)); + +const totalAiBudgetMicros = (args: { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + rates: Rates; +}) => + costPartMicros(args.inputTokens ?? 0, args.rates.input) + + costPartMicros(args.outputTokens ?? 0, args.rates.output) + + costPartMicros(args.cacheReadTokens ?? 0, args.rates.cacheRead) + + costPartMicros(args.cacheWriteTokens ?? 0, args.rates.cacheWrite); +``` + +Files to touch: + +- `apps/web/src/convex/usage.ts` + +Checklist: + +- [ ] Replace `FEATURE_IDS.tokensIn`, `tokensOut`, and `sandboxHours` with `aiBudget`. +- [ ] Replace `ensureUsageAvailable` preflight balance checks with one `requiredBudgetMicros`. +- [ ] Replace `finalizeUsage` tracking calls with one `ai_budget` usage write. +- [ ] Update billing summary return shape to expose one usage metric instead of 3. + +### Phase 4: Usage data plumbing + +- [ ] Thread the actual selected model into budget finalization. +- [ ] Stop assuming all web traffic uses one hard-coded model. +- [ ] Decide what to do with cache tokens in v1. + +Recommended v1: + +- Meter `inputTokens` and `outputTokens`. +- Defer `cacheReadTokens` and `cacheWriteTokens` unless those counts are already available reliably in the web request flow. + +If cache tokens are available later: + +```ts +await ctx.runAction(usageActions.finalizeUsage, { + instanceId: instance._id, + modelId, + questionTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens +}); +``` + +Current gap to resolve: + +- `apps/web/src/convex/http.ts` currently finalizes using question tokens plus char-derived output estimates. +- If possible, prefer actual token counts from the done event or server metadata instead of char heuristics. + +### Phase 5: Project-scoped model wiring + +- [ ] Use `projects.model` when generating sandbox config. +- [ ] Fall back to `claude-haiku-4-5` when `project.model` is unset. +- [ ] Ensure wake flows use the selected project, not just the instance. +- [ ] Ensure live config sync reloads the server with the selected project's model. + +Suggested `generateBtcaConfig` shape: + +```ts +function generateBtcaConfig(args: { + resources: ResourceConfig[]; + model: string; + provider?: string; +}) { + return JSON.stringify( + { + $schema: 'https://btca.dev/btca.schema.json', + resources: args.resources, + model: args.model, + provider: args.provider ?? 'opencode' + }, + null, + 2 + ); +} +``` + +Files to touch: + +- `apps/web/src/convex/instances/actions.ts` +- `apps/web/src/convex/projects.ts` +- `apps/web/src/lib/stores/project.svelte.ts` + +Checklist: + +- [ ] Load project model in wake flow when `projectId` is present. +- [ ] Load project model in resource sync flow. +- [ ] Update `wakeMyInstance` or add a new project-aware wake action for chat. +- [ ] Keep current behavior for projects with no model set. + +### Phase 6: Picker UX + +- [ ] Add a compact picker near the active project/chat context. +- [ ] Add a secondary surface in project settings. +- [ ] Disable switching while a response is streaming. +- [ ] Save immediately and apply immediately when runtime is already running. +- [ ] For stopped sandboxes, save now and show that it applies on wake. + +Recommended copy: + +- `MiniMax M2.5` - `Lowest usage` +- `Claude Haiku 4.5` - `Balanced` +- `GPT-5.4` - `Highest usage` + +Helper text: + +- `Model choice affects how quickly your monthly usage bar fills.` + +Suggested Svelte sketch: + +```svelte + ({ + value: model.id, + label: `${model.label} - ${model.description}` + }))} + disabled={isStreaming || isSaving} +/> +``` + +Files to touch: + +- `apps/web/src/routes/app/chat/[id]/+page.svelte` +- `apps/web/src/lib/components/ProjectSelector.svelte` +- `apps/web/src/lib/components/CreateProjectModal.svelte` +- `apps/web/src/routes/app/settings/resources/+page.svelte` + +UX details: + +- Do not force model selection during project creation for v1. +- Default new projects to Haiku or leave unset and inherit default. +- Show a toast after save: + - running: `Switched this project to GPT-5.4` + - stopped: `Saved. This applies next time the project wakes.` + +### Phase 7: Billing UI refresh + +- [ ] Remove token bucket presentation from billing surfaces. +- [ ] Replace with one AI-usage percentage bar. +- [ ] Do not show dollar figures. +- [ ] Remove sandbox-usage meter if it is no longer enforced. + +Suggested display shape: + +```ts +type BillingSummary = { + usage: { + aiBudget: { + usedPct: number; + remainingPct: number; + isDepleted: boolean; + }; + }; +}; +``` + +Suggested UI copy: + +- heading: `Monthly AI usage` +- label: `42% used` +- helper: `Higher-end models use this faster.` + +Files to touch: + +- `apps/web/src/convex/usage.ts` +- `apps/web/src/lib/billing/plans.ts` +- `apps/web/src/lib/components/pricing/PricingPlans.svelte` +- `apps/web/src/routes/app/settings/+page.svelte` + +### Phase 8: Pricing / marketing copy + +- [ ] Remove copy that implies the web app uses only Haiku. +- [ ] Update plan copy to mention the 3-model selector. +- [ ] Keep messaging qualitative, not numeric. + +Suggested copy direction: + +- `Choose between low, balanced, and high-end models` +- `Monthly AI usage is shared across all models` + +### Phase 9: Analytics + +- [ ] Track model changes. +- [ ] Track which model was used for completed streams. +- [ ] Track budget depletion events. + +Suggested events: + +- `project_model_updated` +- `stream_completed` with `modelId` +- `usage_limit_reached` with `feature: ai_budget` + +### Phase 10: Testing checklist + +- [ ] New project with no model set uses fallback model. +- [ ] Changing model on stopped project persists and applies on wake. +- [ ] Changing model on running project reloads config and keeps chat working. +- [ ] Preflight blocks requests when AI budget is depleted. +- [ ] Billing summary shows one percentage bar only. +- [ ] Free plan still uses message count limits. +- [ ] Chat wake path respects selected project model. +- [ ] Selected model survives page reload and project switching. + +## Recommended Execution Order + +1. Create shared model catalog + pricing snapshot. +2. Convert Autumn product to `ai_budget`. +3. Refactor `usage.ts` to one-budget enforcement. +4. Refactor billing summary + billing UI to one percentage bar. +5. Wire `projects.model` into sandbox config generation and reload. +6. Add project-aware wake path. +7. Add picker UI and toast states. +8. Update copy and analytics. + +## Open Questions + +- Do we meter cache reads in v1 if usage counts are available, or defer them? +- Should the default project explicitly store `claude-haiku-4-5`, or keep fallback logic only? +- Should free plan use a fixed model, or can free users also pick among the 3 models? + +## Success Criteria + +- Users can switch between `minimax-m2.5`, `claude-haiku-4-5`, and `gpt-5.4` per project. +- Billing enforces one AI budget instead of raw token buckets. +- Billing UI shows only percentage-based usage. +- No sandbox-hour enforcement remains in the customer-facing usage path. +- Existing projects without a saved model continue to work. diff --git a/apps/web/autumn.config.ts b/apps/web/autumn.config.ts index 7d5ace40..140f1a7d 100644 --- a/apps/web/autumn.config.ts +++ b/apps/web/autumn.config.ts @@ -1,4 +1,4 @@ -import { feature, product, featureItem, priceItem } from 'atmn'; +import { feature, product, featureItem, pricedFeatureItem, priceItem } from 'atmn'; // Features export const sandboxHours = feature({ @@ -25,6 +25,12 @@ export const chatMessages = feature({ type: 'single_use' }); +export const aiBudget = feature({ + id: 'ai_budget', + name: 'AI Budget', + type: 'single_use' +}); + // Products export const freePlan = product({ id: 'free_plan', @@ -47,6 +53,12 @@ export const btcaPro = product({ interval: 'month' }), + featureItem({ + feature_id: aiBudget.id, + included_usage: 5, + interval: 'month' + }), + featureItem({ feature_id: sandboxHours.id, included_usage: 6, diff --git a/apps/web/src/convex/http.ts b/apps/web/src/convex/http.ts index c8171d54..659317b7 100644 --- a/apps/web/src/convex/http.ts +++ b/apps/web/src/convex/http.ts @@ -15,6 +15,7 @@ import { getInstanceErrorKind, getUserFacingInstanceError } from '../lib/instanceErrors'; +import { getWebSandboxModel } from '../lib/models/webSandboxModels.ts'; import { WebUnhandledError, type WebError } from '../lib/result/errors'; type HttpFlowResult = Result; @@ -83,7 +84,8 @@ type MessageLike = { type ChunkUpdate = | { type: 'add'; chunk: BtcaChunk } - | { type: 'update'; id: string; chunk: Partial }; + | { type: 'update'; id: string; chunk: Partial } + | { type: 'append'; id: string; chunkType: 'text' | 'reasoning'; delta: string }; type BtcaToolState = { status?: 'pending' | 'running' | 'completed' | 'error'; @@ -106,6 +108,9 @@ type BtcaStreamDoneEvent = { outputTokens?: number; reasoningTokens?: number; totalTokens?: number; + cachedTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; }; metrics?: { timing?: { totalMs?: number; genMs?: number }; @@ -164,6 +169,55 @@ type StreamEventPayload = | { type: 'done' } | ChunkUpdate; +const toMessageStats = (doneEvent: BtcaStreamDoneEvent | null) => { + if (!doneEvent) return undefined; + + const inputTokens = doneEvent.usage?.inputTokens; + const outputTokens = + doneEvent.usage?.outputTokens != null || doneEvent.usage?.reasoningTokens != null + ? (doneEvent.usage?.outputTokens ?? 0) + (doneEvent.usage?.reasoningTokens ?? 0) + : undefined; + const cachedTokens = + doneEvent.usage?.cachedTokens ?? + (doneEvent.usage?.cacheReadTokens != null || doneEvent.usage?.cacheWriteTokens != null + ? (doneEvent.usage?.cacheReadTokens ?? 0) + (doneEvent.usage?.cacheWriteTokens ?? 0) + : undefined); + const durationMs = doneEvent.metrics?.timing?.totalMs ?? doneEvent.metrics?.timing?.genMs; + const totalTokensFromParts = [inputTokens, outputTokens, cachedTokens].reduce( + (sum, value) => sum + (value ?? 0), + 0 + ); + const totalTokens = + doneEvent.usage?.totalTokens ?? (totalTokensFromParts > 0 ? totalTokensFromParts : undefined); + const tokensPerSecond = + doneEvent.metrics?.throughput?.totalTokensPerSecond ?? + doneEvent.metrics?.throughput?.outputTokensPerSecond ?? + (durationMs && totalTokens ? totalTokens / (durationMs / 1000) : undefined); + const totalPriceUsd = doneEvent.metrics?.pricing?.costUsd?.total; + + if ( + durationMs == null && + inputTokens == null && + outputTokens == null && + cachedTokens == null && + totalTokens == null && + tokensPerSecond == null && + totalPriceUsd == null + ) { + return undefined; + } + + return { + durationMs, + inputTokens, + outputTokens, + cachedTokens, + totalTokens, + tokensPerSecond, + totalPriceUsd + }; +}; + function jsonResponse(payload: unknown, init: ResponseInit = {}): Response { const headers = new Headers(init.headers); headers.set('Content-Type', 'application/json'); @@ -283,6 +337,14 @@ const chatStream = httpAction(async (ctx, request) => { const threadResources = threadWithMessages.threadResources ?? []; const updatedResources = [...new Set([...threadResources, ...selectedResources])]; + const projectId = threadWithMessages.projectId ?? undefined; + const project = projectId + ? await ctx.runQuery(internal.projects.getInternal, { projectId }) + : null; + const modelId = + project && project.instanceId === instance._id + ? getWebSandboxModel(project.model).id + : getWebSandboxModel().id; const threadMessages: ThreadMessage[] = (threadWithMessages.messages ?? []).map( (messageItem: MessageLike) => ({ role: messageItem.role, @@ -299,7 +361,8 @@ const chatStream = httpAction(async (ctx, request) => { const usageCheck = await ctx.runAction(usageActions.ensureUsageAvailable, { instanceId: instance._id, question: questionWithHistory, - resources: updatedResources + resources: updatedResources, + projectId }); if (!usageCheck?.ok) { @@ -337,6 +400,9 @@ const chatStream = httpAction(async (ctx, request) => { const usageData = usageCheck as { inputTokens?: number; + requiredBudgetMicros?: number; + modelId?: string; + billingMode?: 'ai_budget' | 'legacy'; sandboxUsageHours?: number; }; @@ -351,7 +417,8 @@ const chatStream = httpAction(async (ctx, request) => { resourceCount: updatedResources.length, resources: updatedResources, inputTokens: usageData.inputTokens ?? 0, - sandboxUsageHours: usageData.sandboxUsageHours ?? 0 + modelId: usageData.modelId ?? modelId, + requiredBudgetMicros: usageData.requiredBudgetMicros ?? 0 } }); @@ -385,7 +452,7 @@ const chatStream = httpAction(async (ctx, request) => { sendEvent({ type: 'session', sessionId } as StreamEventPayload); - const serverAccessResult = await ensureServerUrlResult(ctx, instance, sendEvent); + const serverAccessResult = await ensureServerUrlResult(ctx, instance, projectId, sendEvent); if (Result.isError(serverAccessResult)) { throw serverAccessResult.error; } @@ -541,26 +608,28 @@ const chatStream = httpAction(async (ctx, request) => { } await ctx.runMutation(api.messages.updateAssistantMessage, { messageId: assistantMessageId, - content: assistantContent + content: assistantContent, + stats: toMessageStats(doneEvent) }); await ctx.runMutation( instanceMutations.touchActivity, withPrivateApiKey({ instanceId: instance._id }) ); - const outputTokensData = { - questionTokens: usageData.inputTokens ?? 0, - outputChars: outputCharCount, - reasoningChars: reasoningCharCount, - resources: updatedResources, - sandboxUsageHours: usageData.sandboxUsageHours ?? 0 - }; + const actualUsage = doneEvent?.usage; + let chargedBudgetMicros = 0; try { - await ctx.runAction(usageActions.finalizeUsage, { + const finalizeResult = await ctx.runAction(usageActions.finalizeUsage, { instanceId: instance._id, - ...outputTokensData + modelId: usageData.modelId ?? modelId, + inputTokens: actualUsage?.inputTokens ?? usageData.inputTokens ?? 0, + outputTokens: actualUsage?.outputTokens ?? 0, + reasoningTokens: actualUsage?.reasoningTokens ?? 0, + billingMode: usageData.billingMode, + sandboxUsageHours: usageData.sandboxUsageHours ?? 0 }); + chargedBudgetMicros = finalizeResult.chargedBudgetMicros ?? 0; } catch (error) { console.error('Failed to track usage:', error); } @@ -591,8 +660,11 @@ const chatStream = httpAction(async (ctx, request) => { toolCount: toolsUsed.length, resourcesUsed: updatedResources, resourceCount: updatedResources.length, - inputTokens: usageData.inputTokens ?? 0, - sandboxUsageHours: usageData.sandboxUsageHours ?? 0 + modelId: usageData.modelId ?? modelId, + inputTokens: actualUsage?.inputTokens ?? usageData.inputTokens ?? 0, + outputTokens: actualUsage?.outputTokens ?? 0, + reasoningTokens: actualUsage?.reasoningTokens ?? 0, + chargedBudgetMicros } }); @@ -877,7 +949,7 @@ function processStreamEvent( const existing = chunksById.get(textChunkId); if (existing && existing.type === 'text') { existing.text += event.delta; - return { type: 'update', id: textChunkId, chunk: { text: existing.text } }; + return { type: 'append', id: textChunkId, chunkType: 'text', delta: event.delta }; } const chunk: BtcaChunk = { type: 'text', id: textChunkId, text: event.delta }; @@ -891,7 +963,12 @@ function processStreamEvent( const existing = chunksById.get(reasoningChunkId); if (existing && existing.type === 'reasoning') { existing.text += event.delta; - return { type: 'update', id: reasoningChunkId, chunk: { text: existing.text } }; + return { + type: 'append', + id: reasoningChunkId, + chunkType: 'reasoning', + delta: event.delta + }; } const chunk: BtcaChunk = { @@ -934,6 +1011,7 @@ function processStreamEvent( async function ensureServerUrlResult( ctx: ActionCtx, instance: InstanceRecord, + projectId: Id<'projects'> | undefined, sendEvent: (payload: StreamEventPayload) => void ): Promise> { if (instance.state === 'error') { @@ -957,6 +1035,19 @@ async function ensureServerUrlResult( return Result.ok({ serverUrl: instance.serverUrl }); } + if (projectId) { + const syncResult = await ctx.runAction(internal.instances.actions.syncResources, { + instanceId: instance._id, + projectId, + includePrivate: true + }); + if (!syncResult.synced) { + return Result.err( + new WebUnhandledError({ message: 'Failed to sync the selected project configuration' }) + ); + } + } + const previewAccess = await ctx.runAction(internal.instances.actions.getPreviewAccess, { instanceId: instance._id }); @@ -978,7 +1069,7 @@ async function ensureServerUrlResult( try { const result = await ctx.runAction( instanceActions.wake, - withPrivateApiKey({ instanceId: instance._id }) + withPrivateApiKey({ instanceId: instance._id, projectId }) ); const serverUrl = result.serverUrl; if (!serverUrl) { diff --git a/apps/web/src/convex/instances/actions.ts b/apps/web/src/convex/instances/actions.ts index c7f4357c..4ae94e20 100644 --- a/apps/web/src/convex/instances/actions.ts +++ b/apps/web/src/convex/instances/actions.ts @@ -14,6 +14,7 @@ import { instances } from '../apiHelpers'; import { inspectGitHubConnectionForClerkUser } from '../githubAuth'; import { privateAction, withPrivateApiKey } from '../privateWrappers'; import { getInstanceErrorKind, getUserFacingInstanceError } from '../../lib/instanceErrors'; +import { getWebSandboxModel } from '../../lib/models/webSandboxModels.ts'; import { WebAuthError, WebConfigMissingError, @@ -26,8 +27,6 @@ const instanceQueries = instances.queries; const instanceMutations = instances.mutations; const BTCA_SERVER_PORT = 3000; const SANDBOX_IDLE_MINUTES = 2; -const DEFAULT_MODEL = 'claude-haiku-4-5'; -const DEFAULT_PROVIDER = 'opencode'; const BTCA_SERVER_SESSION = 'btca-server-session'; const BTCA_SERVER_LOG_PATH = '/tmp/btca-server.log'; const BTCA_PACKAGE_NAME = 'btca@latest'; @@ -122,7 +121,7 @@ function getDaytonaResult(): InstanceActionResult { return Result.ok(daytonaInstance); } -function generateBtcaConfig(resources: ResourceConfig[]): string { +function generateBtcaConfig(resources: ResourceConfig[], model = getWebSandboxModel()): string { return JSON.stringify( { $schema: 'https://btca.dev/btca.schema.json', @@ -144,8 +143,8 @@ function generateBtcaConfig(resources: ResourceConfig[]): string { specialNotes: resource.specialNotes } ), - model: DEFAULT_MODEL, - provider: DEFAULT_PROVIDER + model: model.id, + provider: model.provider }, null, 2 @@ -358,6 +357,23 @@ async function getResourceConfigs( return [...merged.values()]; } +async function getSandboxModelConfig( + ctx: ActionCtx, + instanceId: Id<'instances'>, + projectId?: Id<'projects'> +) { + if (!projectId) { + return getWebSandboxModel(); + } + + const project = await ctx.runQuery(internal.projects.getInternal, { projectId }); + if (!project || project.instanceId !== instanceId) { + throw new WebValidationError({ message: 'Project not found', field: 'projectId' }); + } + + return getWebSandboxModel(project.model); +} + async function requireInstance( ctx: ActionCtx, instanceId: Id<'instances'> @@ -377,8 +393,12 @@ async function requireInstanceResult( return Result.ok(instance); } -async function uploadBtcaConfig(sandbox: Sandbox, resources: ResourceConfig[]): Promise { - const config = generateBtcaConfig(resources); +async function uploadBtcaConfig( + sandbox: Sandbox, + resources: ResourceConfig[], + model = getWebSandboxModel() +): Promise { + const config = generateBtcaConfig(resources, model); await sandbox.fs.uploadFile(Buffer.from(config), '/root/btca.config.jsonc'); } @@ -840,14 +860,16 @@ async function createSandboxFromScratch( ctx: ActionCtx, instanceId: Id<'instances'>, instance: Doc<'instances'>, + projectId?: Id<'projects'>, includePrivate = true ): Promise<{ sandbox: Sandbox; serverUrl: string }> { requireEnv('OPENCODE_API_KEY'); let step = 'load_resources'; const resources = unwrapInstance( - await withStep(step, () => getResourceConfigs(ctx, instanceId, undefined, includePrivate)) + await withStep(step, () => getResourceConfigs(ctx, instanceId, projectId, includePrivate)) ); + const model = await getSandboxModelConfig(ctx, instanceId, projectId); const daytona = getDaytona(); step = 'create_sandbox'; const sandbox = unwrapInstance( @@ -867,7 +889,7 @@ async function createSandboxFromScratch( step = 'upload_config'; unwrapInstance(await withStep(step, () => syncGitHubAuth(sandbox, instance.clerkId, resources))); step = 'upload_config'; - unwrapInstance(await withStep(step, () => uploadBtcaConfig(sandbox, resources))); + unwrapInstance(await withStep(step, () => uploadBtcaConfig(sandbox, resources, model))); step = 'start_btca'; const serverUrl = unwrapInstance(await withStep(step, () => startBtcaServer(sandbox))).serverUrl; step = 'get_versions'; @@ -930,7 +952,13 @@ async function wakeInstanceInternal( if (!instance.sandboxId) { step = 'create_sandbox'; - const result = await createSandboxFromScratch(ctx, instanceId, instance, includePrivate); + const result = await createSandboxFromScratch( + ctx, + instanceId, + instance, + projectId, + includePrivate + ); serverUrl = result.serverUrl; sandboxId = result.sandbox.id; } else { @@ -939,6 +967,7 @@ async function wakeInstanceInternal( const resources = unwrapInstance( await withStep(step, () => getResourceConfigs(ctx, instanceId, projectId, includePrivate)) ); + const model = await getSandboxModelConfig(ctx, instanceId, projectId); const daytona = getDaytona(); step = 'get_sandbox'; const sandbox = await daytona.get(instance.sandboxId); @@ -950,7 +979,7 @@ async function wakeInstanceInternal( await withStep(step, () => syncGitHubAuth(sandbox, instance.clerkId, resources)) ); step = 'upload_config'; - unwrapInstance(await withStep(step, () => uploadBtcaConfig(sandbox, resources))); + unwrapInstance(await withStep(step, () => uploadBtcaConfig(sandbox, resources, model))); step = 'start_btca'; serverUrl = unwrapInstance(await withStep(step, () => startBtcaServer(sandbox))).serverUrl; sandboxId = instance.sandboxId; @@ -1238,11 +1267,60 @@ async function maybeScheduleSnapshotMigration( } export const wakeMyInstance = action({ - args: {}, + args: { + projectId: v.optional(v.id('projects')) + }, returns: v.object({ serverUrl: v.string() }), - handler: async (ctx): Promise<{ serverUrl: string }> => { + handler: async (ctx, args): Promise<{ serverUrl: string }> => { const instance = await requireAuthenticatedInstance(ctx); - return wakeInstanceInternal(ctx, instance._id); + return wakeInstanceInternal(ctx, instance._id, args.projectId); + } +}); + +export const applyProjectRuntimeConfig = action({ + args: { + projectId: v.id('projects') + }, + returns: v.object({ + applied: v.boolean(), + appliesOnWake: v.boolean() + }), + handler: async ( + ctx, + args + ): Promise<{ + applied: boolean; + appliesOnWake: boolean; + }> => { + const instance = await requireAuthenticatedInstance(ctx); + const project: Doc<'projects'> | null = await ctx.runQuery(internal.projects.getInternal, { + projectId: args.projectId + }); + + if (!project || project.instanceId !== instance._id) { + throw new WebValidationError({ message: 'Project not found', field: 'projectId' }); + } + + if (instance.state !== 'running' || !instance.sandboxId || !instance.serverUrl) { + return { + applied: false, + appliesOnWake: true + }; + } + + const result: { synced: boolean } = await ctx.runAction( + internal.instances.actions.syncResources, + { + instanceId: instance._id, + projectId: args.projectId, + includePrivate: true + } + ); + + return { + applied: result.synced, + appliesOnWake: !result.synced + }; } }); @@ -1438,6 +1516,7 @@ export const syncResources = internalAction({ args.projectId, args.includePrivate ?? true ); + const model = await getSandboxModelConfig(ctx, args.instanceId, args.projectId); const daytona = getDaytona(); const sandbox = await daytona.get(instance.sandboxId); @@ -1447,7 +1526,7 @@ export const syncResources = internalAction({ // Upload the config and reload the server await syncGitHubAuth(sandbox, instance.clerkId, resources); - await uploadBtcaConfig(sandbox, resources); + await uploadBtcaConfig(sandbox, resources, model); const previewAccess = await getPreviewAccessForSandbox( sandbox, instance.serverUrl ?? undefined diff --git a/apps/web/src/convex/mcp.ts b/apps/web/src/convex/mcp.ts index a0f0189b..ef2fa971 100644 --- a/apps/web/src/convex/mcp.ts +++ b/apps/web/src/convex/mcp.ts @@ -674,7 +674,10 @@ export const sync = action({ await ctx.runQuery(internal.resources.listByProject, { projectId }) - ).filter((resource) => resource.visibility !== 'private'); + ).filter( + (resource: { name: string; url?: string; branch?: string; visibility?: string }) => + resource.visibility !== 'private' + ); const synced: string[] = []; const errors: string[] = []; @@ -683,7 +686,7 @@ export const sync = action({ // Process each resource in the config for (const localResource of config.resources) { const existingResource = existingResources.find( - (r) => r.name.toLowerCase() === localResource.name.toLowerCase() + (resource) => resource.name.toLowerCase() === localResource.name.toLowerCase() ); if (existingResource && existingResource.url) { diff --git a/apps/web/src/convex/mcpInternal.ts b/apps/web/src/convex/mcpInternal.ts index e7fc1f65..2871641d 100644 --- a/apps/web/src/convex/mcpInternal.ts +++ b/apps/web/src/convex/mcpInternal.ts @@ -3,6 +3,7 @@ import { Result } from 'better-result'; import type { Id } from './_generated/dataModel'; import { internalMutation } from './_generated/server'; +import { isWebSandboxModelId } from '../lib/models/webSandboxModels.ts'; import { WebConflictError, WebValidationError, type WebError } from '../lib/result/errors'; type McpInternalResult = Result; @@ -169,6 +170,12 @@ export const updateProjectModelInternal = internalMutation({ }, returns: v.null(), handler: async (ctx, args) => { + if (!isWebSandboxModelId(args.model)) { + throwMcpInternalError( + new WebValidationError({ message: 'Unsupported model', field: 'model' }) + ); + } + await ctx.db.patch(args.projectId, { model: args.model }); diff --git a/apps/web/src/convex/messages.ts b/apps/web/src/convex/messages.ts index be96447d..85eda214 100644 --- a/apps/web/src/convex/messages.ts +++ b/apps/web/src/convex/messages.ts @@ -47,6 +47,16 @@ const messageContentValidator = v.union( }) ); +const messageStatsValidator = v.object({ + durationMs: v.optional(v.number()), + inputTokens: v.optional(v.number()), + outputTokens: v.optional(v.number()), + cachedTokens: v.optional(v.number()), + totalTokens: v.optional(v.number()), + tokensPerSecond: v.optional(v.number()), + totalPriceUsd: v.optional(v.number()) +}); + /** * Add a user message to a thread (requires ownership) */ @@ -113,7 +123,8 @@ export const addAssistantMessage = mutation({ args: { threadId: v.id('threads'), content: messageContentValidator, - canceled: v.optional(v.boolean()) + canceled: v.optional(v.boolean()), + stats: v.optional(messageStatsValidator) }, returns: v.id('messages'), handler: async (ctx, args) => { @@ -124,6 +135,7 @@ export const addAssistantMessage = mutation({ role: 'assistant', content: args.content, canceled: args.canceled, + stats: args.stats, createdAt: Date.now() }); @@ -164,6 +176,7 @@ const messageValidator = v.object({ content: messageContentValidator, resources: v.optional(v.array(v.string())), canceled: v.optional(v.boolean()), + stats: v.optional(messageStatsValidator), createdAt: v.number() }); @@ -191,12 +204,13 @@ export const getByThread = query({ export const updateAssistantMessage = mutation({ args: { messageId: v.id('messages'), - content: messageContentValidator + content: messageContentValidator, + stats: v.optional(messageStatsValidator) }, returns: v.null(), handler: async (ctx, args) => { await unwrapAuthResult(await requireMessageOwnershipResult(ctx, args.messageId)); - await ctx.db.patch(args.messageId, { content: args.content }); + await ctx.db.patch(args.messageId, { content: args.content, stats: args.stats }); return null; } }); diff --git a/apps/web/src/convex/projects.ts b/apps/web/src/convex/projects.ts index b3388c72..f7b0f59d 100644 --- a/apps/web/src/convex/projects.ts +++ b/apps/web/src/convex/projects.ts @@ -6,6 +6,7 @@ import type { Doc, Id } from './_generated/dataModel'; import { internalQuery, mutation, query } from './_generated/server'; import { AnalyticsEvents } from './analyticsEvents'; import { getAuthenticatedInstanceResult, unwrapAuthResult } from './authHelpers'; +import { isWebSandboxModelId } from '../lib/models/webSandboxModels.ts'; import { WebConflictError, WebValidationError, type WebError } from '../lib/result/errors'; // Project validator @@ -28,6 +29,12 @@ const throwProjectError = (error: WebError): never => { const projectNotFoundError = (message: string): WebValidationError => new WebValidationError({ message, field: 'project' }); +const validateProjectModel = (model?: string) => { + if (model && !isWebSandboxModelId(model)) { + throwProjectError(new WebValidationError({ message: 'Unsupported model', field: 'model' })); + } +}; + type ProjectDb = { get(id: Id<'projects'>): Promise | null>; }; @@ -138,6 +145,7 @@ export const create = mutation({ returns: v.id('projects'), handler: async (ctx, args) => { const instance = await unwrapAuthResult(await getAuthenticatedInstanceResult(ctx)); + validateProjectModel(args.model); const existing = await ctx.db .query('projects') .withIndex('by_instance_and_name', (q) => @@ -236,6 +244,7 @@ export const updateModel = mutation({ returns: v.null(), handler: async (ctx, args) => { const instance = await unwrapAuthResult(await getAuthenticatedInstanceResult(ctx)); + validateProjectModel(args.model); const projectResult = await requireProjectOwnershipResult(ctx, args.projectId, instance._id); const project = Result.match(projectResult, { ok: (value) => value, diff --git a/apps/web/src/convex/schema.ts b/apps/web/src/convex/schema.ts index 88701812..f5e86113 100644 --- a/apps/web/src/convex/schema.ts +++ b/apps/web/src/convex/schema.ts @@ -35,6 +35,16 @@ const messageContentValidator = v.union( }) ); +const messageStatsValidator = v.object({ + durationMs: v.optional(v.number()), + inputTokens: v.optional(v.number()), + outputTokens: v.optional(v.number()), + cachedTokens: v.optional(v.number()), + totalTokens: v.optional(v.number()), + tokensPerSecond: v.optional(v.number()), + totalPriceUsd: v.optional(v.number()) +}); + export default defineSchema({ instances: defineTable({ clerkId: v.string(), @@ -161,6 +171,7 @@ export default defineSchema({ content: messageContentValidator, resources: v.optional(v.array(v.string())), canceled: v.optional(v.boolean()), + stats: v.optional(messageStatsValidator), createdAt: v.number() }).index('by_thread', ['threadId']), diff --git a/apps/web/src/convex/threads.ts b/apps/web/src/convex/threads.ts index ae78a912..2c5f6c84 100644 --- a/apps/web/src/convex/threads.ts +++ b/apps/web/src/convex/threads.ts @@ -106,6 +106,16 @@ const messageContentValidator = v.union( }) ); +const messageStatsValidator = v.object({ + durationMs: v.optional(v.number()), + inputTokens: v.optional(v.number()), + outputTokens: v.optional(v.number()), + cachedTokens: v.optional(v.number()), + totalTokens: v.optional(v.number()), + tokensPerSecond: v.optional(v.number()), + totalPriceUsd: v.optional(v.number()) +}); + const messageValidator = v.object({ _id: v.id('messages'), _creationTime: v.number(), @@ -114,6 +124,7 @@ const messageValidator = v.object({ content: messageContentValidator, resources: v.optional(v.array(v.string())), canceled: v.optional(v.boolean()), + stats: v.optional(messageStatsValidator), createdAt: v.number() }); diff --git a/apps/web/src/convex/usage.ts b/apps/web/src/convex/usage.ts index 0945934d..9113d7a9 100644 --- a/apps/web/src/convex/usage.ts +++ b/apps/web/src/convex/usage.ts @@ -9,10 +9,17 @@ import { AnalyticsEvents } from './analyticsEvents.js'; import { instances } from './apiHelpers.js'; import { requireInstanceOwnershipActionResult, unwrapAuthResult } from './authHelpers.js'; import { withPrivateApiKey } from './privateWrappers.js'; +import { + PRO_AI_BUDGET_MICROS, + getPreflightAiBudgetMicros, + totalAiBudgetMicros +} from '../lib/billing/aiBudget.ts'; +import { getWebSandboxModel } from '../lib/models/webSandboxModels.ts'; import { WebConfigMissingError, WebExternalDependencyError, WebUnhandledError, + WebValidationError, type WebError } from '../lib/result/errors.js'; @@ -22,24 +29,43 @@ type FeatureMetrics = { included: number; }; +type LegacyUsageMetrics = { + tokensIn: FeatureMetrics; + tokensOut: FeatureMetrics; + sandboxHours: FeatureMetrics; +}; + +type BillingMode = 'ai_budget' | 'legacy'; + +type ProUsageMetrics = + | { + mode: 'ai_budget'; + aiBudget: FeatureMetrics; + } + | { + mode: 'legacy'; + aiBudget: FeatureMetrics; + legacyMetrics: LegacyUsageMetrics; + }; + type UsageCheckResult = | { ok: false; reason: 'subscription_required' | 'free_limit_reached' } | { ok: boolean; reason: string | null; metrics: { - tokensIn: FeatureMetrics; - tokensOut: FeatureMetrics; - sandboxHours: FeatureMetrics; + aiBudget: FeatureMetrics; }; inputTokens: number; + requiredBudgetMicros: number; + modelId: string; + billingMode: BillingMode; sandboxUsageHours: number; customerId: string; }; type FinalizeUsageResult = { - outputTokens: number; - sandboxUsageHours: number; + chargedBudgetMicros: number; customerId: string; }; @@ -57,9 +83,7 @@ type BillingSummaryResult = { customer: { name: null; email: null }; paymentMethod: unknown; usage: { - tokensIn: UsageMetricDisplay; - tokensOut: UsageMetricDisplay; - sandboxHours: UsageMetricDisplay; + aiBudget: UsageMetricDisplay; }; freeMessages?: { used: number; @@ -80,9 +104,10 @@ type SubscriptionSnapshot = { canceledAt?: number | null; }; -const SANDBOX_IDLE_MINUTES = 2; const CHARS_PER_TOKEN = 4; +const SANDBOX_IDLE_MINUTES = 2; const FEATURE_IDS = { + aiBudget: 'ai_budget', tokensIn: 'tokens_in', tokensOut: 'tokens_out', sandboxHours: 'sandbox_hours', @@ -135,11 +160,6 @@ function estimateTokensFromText(text: string): number { return Math.max(1, Math.ceil(trimmed.length / CHARS_PER_TOKEN)); } -function estimateTokensFromChars(chars: number): number { - if (!Number.isFinite(chars) || chars <= 0) return 0; - return Math.max(1, Math.ceil(chars / CHARS_PER_TOKEN)); -} - function estimateSandboxUsageHours(params: { lastActiveAt?: number | null; now: number }): number { const maxWindowMs = SANDBOX_IDLE_MINUTES * 60 * 1000; if (!params.lastActiveAt) { @@ -177,6 +197,131 @@ const unwrapUsage = (result: UsageResult): T => { }); }; +const isMissingFeatureError = (error: WebError, featureId: string) => + error instanceof WebExternalDependencyError && + error.message.toLowerCase().includes(`feature ${featureId} not found`); + +const toUsageMetric = (args: { usage: number; included: number; balance: number }) => { + const usedPct = args.included > 0 ? clampPercent((args.usage / args.included) * 100) : 0; + const remainingPct = clampPercent(100 - usedPct); + return { + usedPct, + remainingPct, + isDepleted: remainingPct <= 0 || args.balance <= 0 + }; +}; + +async function getLegacyUsageMetrics(customerId: string) { + const [tokensInResult, tokensOutResult, sandboxHoursResult] = await Promise.all([ + checkFeature({ + customerId, + featureId: FEATURE_IDS.tokensIn + }), + checkFeature({ + customerId, + featureId: FEATURE_IDS.tokensOut + }), + checkFeature({ + customerId, + featureId: FEATURE_IDS.sandboxHours + }) + ]); + + return { + tokensIn: unwrapUsage(tokensInResult), + tokensOut: unwrapUsage(tokensOutResult), + sandboxHours: unwrapUsage(sandboxHoursResult) + }; +} + +const hasLegacyUsageEntitlement = (legacyMetrics: LegacyUsageMetrics) => + legacyMetrics.tokensIn.included > 0 || + legacyMetrics.tokensOut.included > 0 || + legacyMetrics.sandboxHours.included > 0; + +const toLegacyAiBudgetMetric = (legacyMetrics: LegacyUsageMetrics) => ({ + usage: Math.max( + toUsageMetric(legacyMetrics.tokensIn).usedPct, + toUsageMetric(legacyMetrics.tokensOut).usedPct, + toUsageMetric(legacyMetrics.sandboxHours).usedPct + ), + balance: Math.min( + legacyMetrics.tokensIn.balance, + legacyMetrics.tokensOut.balance, + legacyMetrics.sandboxHours.balance + ), + included: 100 +}); + +async function resolveProUsageMetrics(args: { + customerId: string; + requiredBalance?: number; +}): Promise> { + const aiBudgetResult = await checkFeature({ + customerId: args.customerId, + featureId: FEATURE_IDS.aiBudget, + requiredBalance: args.requiredBalance + }); + + if (Result.isError(aiBudgetResult)) { + if (!isMissingFeatureError(aiBudgetResult.error, FEATURE_IDS.aiBudget)) { + return Result.err(aiBudgetResult.error); + } + + const legacyMetrics = await getLegacyUsageMetrics(args.customerId); + return Result.ok({ + mode: 'legacy' as const, + aiBudget: toLegacyAiBudgetMetric(legacyMetrics), + legacyMetrics + }); + } + + if (aiBudgetResult.value.included > 0) { + return Result.ok({ + mode: 'ai_budget' as const, + aiBudget: aiBudgetResult.value + }); + } + + try { + const legacyMetrics = await getLegacyUsageMetrics(args.customerId); + if (hasLegacyUsageEntitlement(legacyMetrics)) { + return Result.ok({ + mode: 'legacy' as const, + aiBudget: toLegacyAiBudgetMetric(legacyMetrics), + legacyMetrics + }); + } + } catch { + // No legacy features left to fall back to. + } + + return Result.ok({ + mode: 'ai_budget' as const, + aiBudget: aiBudgetResult.value + }); +} + +async function getResolvedModelId(args: { + ctx: ActionCtx; + instanceId: Doc<'instances'>['_id']; + projectId?: Doc<'projects'>['_id']; +}) { + if (!args.projectId) { + return getWebSandboxModel().id; + } + + const project = await args.ctx.runQuery(internal.projects.getInternal, { + projectId: args.projectId + }); + + if (!project || project.instanceId !== args.instanceId) { + throw new WebValidationError({ message: 'Project not found', field: 'projectId' }); + } + + return getWebSandboxModel(project.model).id; +} + async function getOrCreateCustomer(user: { clerkId: string; email?: string | null; @@ -542,7 +687,8 @@ export const ensureUsageAvailable = action({ args: { instanceId: v.id('instances'), question: v.string(), - resources: v.array(v.string()) + resources: v.array(v.string()), + projectId: v.optional(v.id('projects')) }, returns: v.union( v.object({ @@ -553,11 +699,12 @@ export const ensureUsageAvailable = action({ ok: v.boolean(), reason: v.union(v.string(), v.null()), metrics: v.object({ - tokensIn: featureMetricsValidator, - tokensOut: featureMetricsValidator, - sandboxHours: featureMetricsValidator + aiBudget: featureMetricsValidator }), inputTokens: v.number(), + requiredBudgetMicros: v.number(), + modelId: v.string(), + billingMode: v.union(v.literal('ai_budget'), v.literal('legacy')), sandboxUsageHours: v.number(), customerId: v.string() }) @@ -579,6 +726,11 @@ export const ensureUsageAvailable = action({ : undefined) }) ); + const modelId = await getResolvedModelId({ + ctx, + instanceId: instance._id, + projectId: args.projectId + }); const activeProduct = getActiveProduct(autumnCustomer.products); await syncSubscriptionState(ctx, instance, getSubscriptionSnapshot(activeProduct)); if (!activeProduct) { @@ -621,11 +773,12 @@ export const ensureUsageAvailable = action({ ok: true, reason: null, metrics: { - tokensIn: { usage: 0, balance: 0, included: 0 }, - tokensOut: { usage: 0, balance: 0, included: 0 }, - sandboxHours: { usage: 0, balance: 0, included: 0 } + aiBudget: { usage: 0, balance: 0, included: PRO_AI_BUDGET_MICROS } }, inputTokens: 0, + requiredBudgetMicros: 0, + modelId, + billingMode: 'ai_budget', sandboxUsageHours: 0, customerId: autumnCustomer.id ?? instance.clerkId }; @@ -633,59 +786,71 @@ export const ensureUsageAvailable = action({ if (isProPlan) { const inputTokens = estimateTokensFromText(args.question); - const now = Date.now(); const sandboxUsageHours = args.resources.length - ? estimateSandboxUsageHours({ lastActiveAt: instance.lastActiveAt, now }) + ? estimateSandboxUsageHours({ lastActiveAt: instance.lastActiveAt, now: Date.now() }) : 0; - - const requiredTokensIn = inputTokens > 0 ? inputTokens : undefined; - const requiredTokensOut = 1; - const requiredSandboxHours = sandboxUsageHours > 0 ? sandboxUsageHours : undefined; - - const [tokensInResult, tokensOutResult, sandboxHoursResult] = await Promise.all([ - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensIn, - requiredBalance: requiredTokensIn - }), - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensOut, - requiredBalance: requiredTokensOut - }), - checkFeature({ + const requiredBudgetMicros = getPreflightAiBudgetMicros({ + modelId, + inputTokens + }); + const proUsage = unwrapUsage( + await resolveProUsageMetrics({ customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.sandboxHours, - requiredBalance: requiredSandboxHours + requiredBalance: requiredBudgetMicros }) - ]); - const tokensIn = unwrapUsage(tokensInResult); - const tokensOut = unwrapUsage(tokensOutResult); - const sandboxHours = unwrapUsage(sandboxHoursResult); + ); - const hasEnough = (balance: number, required?: number) => - required == null ? balance > 0 : balance >= required; + if (proUsage.mode === 'legacy') { + const requiredTokensIn = inputTokens > 0 ? inputTokens : undefined; + const requiredTokensOut = 1; + const requiredSandboxHours = sandboxUsageHours > 0 ? sandboxUsageHours : undefined; + const hasEnough = (balance: number, required?: number) => + required == null ? balance > 0 : balance >= required; + const ok = + hasEnough(proUsage.legacyMetrics.tokensIn.balance, requiredTokensIn) && + hasEnough(proUsage.legacyMetrics.tokensOut.balance, requiredTokensOut) && + hasEnough(proUsage.legacyMetrics.sandboxHours.balance, requiredSandboxHours); + + if (!ok) { + await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { + distinctId: instance.clerkId, + event: AnalyticsEvents.USAGE_LIMIT_REACHED, + properties: { + instanceId: args.instanceId, + limitTypes: ['tokensIn', 'tokensOut', 'sandboxHours'], + modelId, + tokensInBalance: proUsage.legacyMetrics.tokensIn.balance, + tokensOutBalance: proUsage.legacyMetrics.tokensOut.balance, + sandboxHoursBalance: proUsage.legacyMetrics.sandboxHours.balance + } + }); + } - const ok = - hasEnough(tokensIn.balance, requiredTokensIn) && - hasEnough(tokensOut.balance, requiredTokensOut) && - hasEnough(sandboxHours.balance, requiredSandboxHours); + return { + ok, + reason: ok ? null : 'limit_reached', + metrics: { aiBudget: proUsage.aiBudget }, + inputTokens, + requiredBudgetMicros, + modelId, + billingMode: 'legacy', + sandboxUsageHours, + customerId: autumnCustomer.id ?? instance.clerkId + }; + } - if (!ok) { - const limitTypes: string[] = []; - if (!hasEnough(tokensIn.balance, requiredTokensIn)) limitTypes.push('tokensIn'); - if (!hasEnough(tokensOut.balance, requiredTokensOut)) limitTypes.push('tokensOut'); - if (!hasEnough(sandboxHours.balance, requiredSandboxHours)) limitTypes.push('sandboxHours'); + const ok = proUsage.aiBudget.balance >= requiredBudgetMicros; + if (!ok) { await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { distinctId: instance.clerkId, event: AnalyticsEvents.USAGE_LIMIT_REACHED, properties: { instanceId: args.instanceId, - limitTypes, - tokensInBalance: tokensIn.balance, - tokensOutBalance: tokensOut.balance, - sandboxHoursBalance: sandboxHours.balance + limitTypes: ['aiBudget'], + modelId, + requiredBudgetMicros, + aiBudgetBalance: proUsage.aiBudget.balance } }); } @@ -694,11 +859,12 @@ export const ensureUsageAvailable = action({ ok, reason: ok ? null : 'limit_reached', metrics: { - tokensIn, - tokensOut, - sandboxHours + aiBudget: proUsage.aiBudget }, inputTokens, + requiredBudgetMicros, + modelId, + billingMode: 'ai_budget', sandboxUsageHours, customerId: autumnCustomer.id ?? instance.clerkId }; @@ -714,15 +880,15 @@ export const ensureUsageAvailable = action({ export const finalizeUsage = action({ args: { instanceId: v.id('instances'), - questionTokens: v.number(), - outputChars: v.number(), - reasoningChars: v.number(), - resources: v.array(v.string()), + modelId: v.string(), + inputTokens: v.number(), + outputTokens: v.number(), + reasoningTokens: v.optional(v.number()), + billingMode: v.optional(v.union(v.literal('ai_budget'), v.literal('legacy'))), sandboxUsageHours: v.optional(v.number()) }, returns: v.object({ - outputTokens: v.number(), - sandboxUsageHours: v.number(), + chargedBudgetMicros: v.number(), customerId: v.string() }), handler: async (ctx, args): Promise => { @@ -759,38 +925,85 @@ export const finalizeUsage = action({ ); } - const outputTokens = isProPlan - ? estimateTokensFromChars(args.outputChars + args.reasoningChars) + const chargedBudgetMicros = isProPlan + ? totalAiBudgetMicros({ + modelId: args.modelId, + inputTokens: args.inputTokens, + outputTokens: args.outputTokens, + reasoningTokens: args.reasoningTokens + }) : 0; - const sandboxUsageHours = isProPlan ? (args.sandboxUsageHours ?? 0) : 0; - if (isProPlan) { - if (args.questionTokens > 0) { - tasks.push( - trackUsage({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensIn, - value: args.questionTokens - }) - ); - } - if (outputTokens > 0) { - tasks.push( - trackUsage({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensOut, - value: outputTokens - }) - ); - } - if (sandboxUsageHours > 0) { - tasks.push( - trackUsage({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.sandboxHours, - value: sandboxUsageHours - }) - ); + if (isProPlan && chargedBudgetMicros > 0) { + if (args.billingMode === 'legacy') { + if (args.inputTokens > 0) { + tasks.push( + trackUsage({ + customerId: autumnCustomer.id ?? instance.clerkId, + featureId: FEATURE_IDS.tokensIn, + value: args.inputTokens + }) + ); + } + if (args.outputTokens + (args.reasoningTokens ?? 0) > 0) { + tasks.push( + trackUsage({ + customerId: autumnCustomer.id ?? instance.clerkId, + featureId: FEATURE_IDS.tokensOut, + value: args.outputTokens + (args.reasoningTokens ?? 0) + }) + ); + } + if ((args.sandboxUsageHours ?? 0) > 0) { + tasks.push( + trackUsage({ + customerId: autumnCustomer.id ?? instance.clerkId, + featureId: FEATURE_IDS.sandboxHours, + value: args.sandboxUsageHours ?? 0 + }) + ); + } + } else { + const trackAiBudgetResult = await trackUsage({ + customerId: autumnCustomer.id ?? instance.clerkId, + featureId: FEATURE_IDS.aiBudget, + value: chargedBudgetMicros + }); + + if ( + Result.isError(trackAiBudgetResult) && + isMissingFeatureError(trackAiBudgetResult.error, FEATURE_IDS.aiBudget) + ) { + if (args.inputTokens > 0) { + tasks.push( + trackUsage({ + customerId: autumnCustomer.id ?? instance.clerkId, + featureId: FEATURE_IDS.tokensIn, + value: args.inputTokens + }) + ); + } + if (args.outputTokens + (args.reasoningTokens ?? 0) > 0) { + tasks.push( + trackUsage({ + customerId: autumnCustomer.id ?? instance.clerkId, + featureId: FEATURE_IDS.tokensOut, + value: args.outputTokens + (args.reasoningTokens ?? 0) + }) + ); + } + if ((args.sandboxUsageHours ?? 0) > 0) { + tasks.push( + trackUsage({ + customerId: autumnCustomer.id ?? instance.clerkId, + featureId: FEATURE_IDS.sandboxHours, + value: args.sandboxUsageHours ?? 0 + }) + ); + } + } else if (Result.isError(trackAiBudgetResult)) { + throwUsageError(trackAiBudgetResult.error); + } } } @@ -802,8 +1015,7 @@ export const finalizeUsage = action({ } return { - outputTokens, - sandboxUsageHours, + chargedBudgetMicros, customerId: autumnCustomer.id ?? instance.clerkId }; } @@ -830,9 +1042,7 @@ export const getBillingSummary = action({ customer: v.object({ name: v.null(), email: v.null() }), paymentMethod: v.any(), usage: v.object({ - tokensIn: usageMetricDisplayValidator, - tokensOut: usageMetricDisplayValidator, - sandboxHours: usageMetricDisplayValidator + aiBudget: usageMetricDisplayValidator }), freeMessages: v.optional( v.object({ @@ -876,39 +1086,18 @@ export const getBillingSummary = action({ canceledAt: activeProduct?.canceled_at ?? undefined }); - const [tokensInResult, tokensOutResult, sandboxHoursResult, chatMessagesResult] = - await Promise.all([ - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensIn - }), - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.tokensOut - }), - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.sandboxHours - }), - checkFeature({ - customerId: autumnCustomer.id ?? instance.clerkId, - featureId: FEATURE_IDS.chatMessages - }) - ]); - const tokensIn = unwrapUsage(tokensInResult); - const tokensOut = unwrapUsage(tokensOutResult); - const sandboxHours = unwrapUsage(sandboxHoursResult); + const [proUsageResult, chatMessagesResult] = await Promise.all([ + resolveProUsageMetrics({ + customerId: autumnCustomer.id ?? instance.clerkId + }), + checkFeature({ + customerId: autumnCustomer.id ?? instance.clerkId, + featureId: FEATURE_IDS.chatMessages + }) + ]); + const proUsage = unwrapUsage(proUsageResult); const chatMessages = unwrapUsage(chatMessagesResult); - - const toUsageMetric = (args: { usage: number; included: number; balance: number }) => { - const usedPct = args.included > 0 ? clampPercent((args.usage / args.included) * 100) : 0; - const remainingPct = clampPercent(100 - usedPct); - return { - usedPct, - remainingPct, - isDepleted: remainingPct <= 0 || args.balance <= 0 - }; - }; + const aiBudgetUsage = toUsageMetric(proUsage.aiBudget); const result: BillingSummaryResult = { plan, @@ -921,9 +1110,7 @@ export const getBillingSummary = action({ }, paymentMethod: autumnCustomer.payment_method ?? null, usage: { - tokensIn: toUsageMetric(tokensIn), - tokensOut: toUsageMetric(tokensOut), - sandboxHours: toUsageMetric(sandboxHours) + aiBudget: aiBudgetUsage } }; diff --git a/apps/web/src/lib/billing/aiBudget.ts b/apps/web/src/lib/billing/aiBudget.ts new file mode 100644 index 00000000..62bc10dc --- /dev/null +++ b/apps/web/src/lib/billing/aiBudget.ts @@ -0,0 +1,43 @@ +import { getWebSandboxModel } from '../models/webSandboxModels.ts'; + +export const USD_MICROS_PER_USD = 1_000_000; +export const PRO_AI_BUDGET_USD = 5; +export const PRO_AI_BUDGET_MICROS = PRO_AI_BUDGET_USD * USD_MICROS_PER_USD; + +const toUsdMicros = (usd?: number) => (usd == null ? 0 : Math.round(usd * USD_MICROS_PER_USD)); + +const costPartMicros = (tokens: number, usdPerMTokens?: number) => { + if (tokens <= 0 || usdPerMTokens == null) return 0; + return Math.max(1, Math.round((tokens * toUsdMicros(usdPerMTokens)) / 1_000_000)); +}; + +export const totalAiBudgetMicros = (args: { + modelId?: string | null; + inputTokens?: number; + outputTokens?: number; + reasoningTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; +}) => { + const model = getWebSandboxModel(args.modelId); + const outputTokens = (args.outputTokens ?? 0) + (args.reasoningTokens ?? 0); + const cacheWriteRate = + 'cacheWrite' in model.ratesUsdPerMTokens ? model.ratesUsdPerMTokens.cacheWrite : undefined; + + return ( + costPartMicros(args.inputTokens ?? 0, model.ratesUsdPerMTokens.input) + + costPartMicros(outputTokens, model.ratesUsdPerMTokens.output) + + costPartMicros(args.cacheReadTokens ?? 0, model.ratesUsdPerMTokens.cacheRead) + + costPartMicros(args.cacheWriteTokens ?? 0, cacheWriteRate) + ); +}; + +export const getPreflightAiBudgetMicros = (args: { + modelId?: string | null; + inputTokens?: number; +}) => + totalAiBudgetMicros({ + modelId: args.modelId, + inputTokens: args.inputTokens, + outputTokens: 1 + }); diff --git a/apps/web/src/lib/billing/plans.ts b/apps/web/src/lib/billing/plans.ts index cae327c8..f4c708d8 100644 --- a/apps/web/src/lib/billing/plans.ts +++ b/apps/web/src/lib/billing/plans.ts @@ -1,20 +1,24 @@ +import { PRO_AI_BUDGET_MICROS, PRO_AI_BUDGET_USD } from './aiBudget.ts'; +import { WEB_SANDBOX_MODELS } from '../models/webSandboxModels.ts'; + export const BILLING_PLAN = { id: 'btca_pro', name: 'Pro', priceUsd: 8, interval: 'month', - model: 'claude-haiku-4-5', - limits: { - tokensIn: 1_500_000, - tokensOut: 300_000, - sandboxHours: 6 - } + aiBudgetUsd: PRO_AI_BUDGET_USD, + aiBudgetMicros: PRO_AI_BUDGET_MICROS, + models: WEB_SANDBOX_MODELS.map(({ id, label, tier, description }) => ({ + id, + label, + tier, + description + })) } as const; export const FEATURE_IDS = { - tokensIn: 'tokens_in', - tokensOut: 'tokens_out', - sandboxHours: 'sandbox_hours' + aiBudget: 'ai_budget', + chatMessages: 'chat_messages' } as const; export const SUPPORT_URL = 'https://x.com/davis7'; diff --git a/apps/web/src/lib/billing/types.ts b/apps/web/src/lib/billing/types.ts index c3e40b24..b7d28354 100644 --- a/apps/web/src/lib/billing/types.ts +++ b/apps/web/src/lib/billing/types.ts @@ -23,9 +23,7 @@ export type BillingSummary = { }; } | null; usage: { - tokensIn: UsageMetric; - tokensOut: UsageMetric; - sandboxHours: UsageMetric; + aiBudget: UsageMetric; }; freeMessages?: { used: number; diff --git a/apps/web/src/lib/components/ChatMessages.svelte b/apps/web/src/lib/components/ChatMessages.svelte index 02948973..3f01374a 100644 --- a/apps/web/src/lib/components/ChatMessages.svelte +++ b/apps/web/src/lib/components/ChatMessages.svelte @@ -5,7 +5,7 @@ import DOMPurify from 'isomorphic-dompurify'; import ToolCallSummary from '$lib/components/ToolCallSummary.svelte'; import { getChatHighlighter } from '../shiki/chatHighlighter.ts'; - import type { Message, BtcaChunk, AssistantContent } from '$lib/types'; + import type { Message, BtcaChunk, AssistantContent, MessageStats } from '$lib/types'; // Type guards for AssistantContent (can be string | { type: 'text' } | { type: 'chunks' }) function isTextContent(content: AssistantContent): content is { type: 'text'; content: string } { @@ -306,6 +306,53 @@ function getRenderableChunks(chunks: BtcaChunk[]): BtcaChunk[] { return sortChunks(chunks).filter((chunk) => chunk.type !== 'tool'); } + + function formatDuration(durationMs: number) { + const seconds = durationMs / 1000; + return seconds >= 10 ? `${seconds.toFixed(1)}s` : `${seconds.toFixed(2)}s`; + } + + function formatTokens(totalTokens: number) { + return `${new Intl.NumberFormat().format(totalTokens)} tokens`; + } + + function formatTokenValue(label: string, totalTokens: number) { + return `${label} ${new Intl.NumberFormat().format(totalTokens)}`; + } + + function formatTokensPerSecond(tokensPerSecond: number) { + return `${tokensPerSecond.toFixed(1)} tok/s`; + } + + function formatUsd(value: number) { + const abs = Math.abs(value); + const decimals = abs >= 1 ? 2 : abs >= 0.01 ? 4 : 6; + return `$${value.toFixed(decimals).replace(/\.?0+$/, '')}`; + } + + function getStatsParts(stats: MessageStats | undefined) { + if (!stats) return []; + + const hasSplitTokenStats = + stats.inputTokens != null || stats.outputTokens != null || stats.cachedTokens != null; + + return [ + stats.durationMs != null ? formatDuration(stats.durationMs) : null, + hasSplitTokenStats + ? formatTokenValue('in', stats.inputTokens ?? 0) + : stats.totalTokens != null + ? formatTokens(stats.totalTokens) + : null, + hasSplitTokenStats ? formatTokenValue('out', stats.outputTokens ?? 0) : null, + hasSplitTokenStats + ? stats.cachedTokens != null + ? formatTokenValue('cached', stats.cachedTokens) + : 'cached n/a' + : null, + stats.tokensPerSecond != null ? formatTokensPerSecond(stats.tokensPerSecond) : null, + stats.totalPriceUsd != null ? formatUsd(stats.totalPriceUsd) : null + ].filter((part): part is string => part !== null); + }
@@ -349,6 +396,7 @@ {/if}
{:else if message.role === 'assistant'} + {@const statsParts = getStatsParts(message.stats)}
AI @@ -382,7 +430,13 @@ {/each}
{/if} -
+
+ {#if statsParts.length > 0} +
+ Stats: + {statsParts.join(' • ')} +
+ {/if} + + {#if isOpen} +
+ {#each WEB_SANDBOX_MODELS as model} + + {/each} +
+ {/if} +
+{/if} + + diff --git a/apps/web/src/lib/components/ProvisioningModal.svelte b/apps/web/src/lib/components/ProvisioningModal.svelte index 2eb19977..d91f0aef 100644 --- a/apps/web/src/lib/components/ProvisioningModal.svelte +++ b/apps/web/src/lib/components/ProvisioningModal.svelte @@ -15,10 +15,14 @@ const hasInstance = $derived(Boolean(instanceStore.instance)); const instanceState = $derived(instanceStore.state ?? ''); const hasProvisionedAt = $derived(Boolean(instanceStore.instance?.provisionedAt)); + const ensureStatus = $derived(instanceStore.ensureStatus); + const isEnsureProvisioning = $derived( + ensureStatus === 'created' || ensureStatus === 'provisioning' + ); const isVisible = $derived.by(() => { if (instanceStore.isLoading) return false; - if (isBootstrapping) return true; + if (isBootstrapping && isEnsureProvisioning) return true; if (!hasInstance) return false; if (hasProvisionedAt) return false; return instanceState === 'unprovisioned' || instanceState === 'provisioning'; diff --git a/apps/web/src/lib/components/pricing/PricingPlans.svelte b/apps/web/src/lib/components/pricing/PricingPlans.svelte index b5a37a5d..1d819d23 100644 --- a/apps/web/src/lib/components/pricing/PricingPlans.svelte +++ b/apps/web/src/lib/components/pricing/PricingPlans.svelte @@ -1,6 +1,6 @@