From 268e626ff2629cc6062470bfb8d8cac86ea8dfd7 Mon Sep 17 00:00:00 2001 From: iamjr15 Date: Sun, 21 Jun 2026 14:41:16 +0530 Subject: [PATCH] =?UTF-8?q?feat(byok):=20free=20DeepSeek=20tier=20?= =?UTF-8?q?=E2=80=94=20200K=20platform-credited=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give every user a lifetime 200,000-token allowance on deepseek-v4-flash via a platform DeepSeek key, so keyless users can build with zero setup. Their own keys always take precedence; explicit non-free picks are never silently swapped to/from free credits. - types: deepseek provider + catalog entry + FREE_DEEPSEEK_* consts; freeDeepseek on profile; deepseek_free_quota_exhausted error code - byok: DeepSeek key validator (api.deepseek.com/models) - db: free_deepseek_tokens_used counter (migration 0004) + atomic per-step metering in recordAgentRunUsage - agent-core: @ai-sdk/deepseek model wiring, non-thinking tool-loop - agent-worker: 3-case credential resolution (own key → platform-free flash → OpenRouter), creditSource threading, synchronous DO-local free-quota hard stop, $0 cost for free runs, canonical slug attribution - gateway/web: Models page free-credits row + usage meter + DeepSeek BYOK - ops: DEEPSEEK_PLATFORM_API_KEY Secrets Store binding + sync-secrets spec --- apps/agent-worker/.dev.vars.example | 4 + apps/agent-worker/src/agent-routing.ts | 1 + .../agent-run-budget-persistence.ts | 19 +- .../src/durable-objects/agent-run-budget.ts | 13 + .../durable-objects/agent-run-cost-caps.ts | 53 +- .../src/durable-objects/agent-run-env.ts | 1 + .../agent-run-mastra-stream.ts | 6 + .../src/durable-objects/agent-run-schemas.ts | 3 + .../agent-run-status-persistence.ts | 2 + .../agent-run-stream-driver.ts | 5 + .../src/durable-objects/agent-run.ts | 23 + .../src/durable-objects/llm-provider.ts | 154 +- apps/agent-worker/wrangler.jsonc | 5 + .../src/openapi-account-routes.ts | 13 +- apps/gateway-worker/src/profile-routes.ts | 18 +- .../src/components/settings/agents-panel.tsx | 72 +- .../settings/provider-keys-panel.tsx | 7 + packages/agent-core/package.json | 1 + packages/agent-core/src/index.ts | 3 + .../agent-core/src/mastra/agents/general.ts | 34 +- .../agent-core/src/mastra/agents/index.ts | 3 + packages/agent-core/src/mastra/llm-context.ts | 4 +- .../src/mastra/tools/browser-runtime.ts | 24 +- .../src/mastra/tools/run-code-execution.ts | 3 + packages/byok/src/provider-validation.ts | 11 + .../db/drizzle/0004_amused_steve_rogers.sql | 1 + packages/db/drizzle/meta/0004_snapshot.json | 1266 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/billing.ts | 25 +- packages/db/src/index.ts | 3 + packages/db/src/runs.ts | 15 + packages/db/src/schema/billing.ts | 6 + packages/env/src/worker.ts | 3 + packages/types/src/api.ts | 1 + packages/types/src/errors.ts | 1 + packages/types/src/index.ts | 2 + packages/types/src/models.ts | 15 + packages/types/src/profile.ts | 13 +- pnpm-lock.yaml | 31 + pnpm-workspace.yaml | 1 + scripts/sync-secrets.ts | 1 + 41 files changed, 1833 insertions(+), 40 deletions(-) create mode 100644 packages/db/drizzle/0004_amused_steve_rogers.sql create mode 100644 packages/db/drizzle/meta/0004_snapshot.json diff --git a/apps/agent-worker/.dev.vars.example b/apps/agent-worker/.dev.vars.example index d2b1d06..15b28b6 100644 --- a/apps/agent-worker/.dev.vars.example +++ b/apps/agent-worker/.dev.vars.example @@ -13,3 +13,7 @@ OUTPUT_DOWNLOAD_SIGNING_SECRET= # Used to verify internal DSR maintenance calls from webhooks-worker. INTERNAL_MAINTENANCE_SECRET= + +# Platform DeepSeek API key for the free 200K-token tier (https://platform.deepseek.com/api_keys). +# Leave blank to disable the free tier locally; set a real key to test free DeepSeek runs. +DEEPSEEK_PLATFORM_API_KEY= diff --git a/apps/agent-worker/src/agent-routing.ts b/apps/agent-worker/src/agent-routing.ts index 2451e43..d5bf072 100644 --- a/apps/agent-worker/src/agent-routing.ts +++ b/apps/agent-worker/src/agent-routing.ts @@ -172,6 +172,7 @@ export async function startAgentRun( ...(run.importRepoUrl ? { importRepoUrl: run.importRepoUrl } : {}), messageText, model: body.model ?? run.modelId, + modelExplicit: Boolean(body.model?.trim()), projectId: run.projectId, ...(run.projectMode ? { projectMode: run.projectMode } : {}), ...(policy.quotaWarning ? { quotaWarning: policy.quotaWarning } : {}), diff --git a/apps/agent-worker/src/durable-objects/agent-run-budget-persistence.ts b/apps/agent-worker/src/durable-objects/agent-run-budget-persistence.ts index 8ec9fd1..fbb4a3c 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-budget-persistence.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-budget-persistence.ts @@ -5,6 +5,7 @@ import type { StartRunInput } from "./agent-run-schemas"; import { persistAgentRunUsage } from "./agent-run-status-persistence"; import { appendBudgetEvent, + getRunStateValue, readStoredRunSnapshot, type StoredBudgetSnapshot, } from "./agent-run-storage"; @@ -30,8 +31,14 @@ export async function recordBudgetDelta( event: BudgetDelta, ): Promise { const storedBeforeAppend = readStoredRunSnapshot(ctx); - const model = input.model ?? storedBeforeAppend?.modelId ?? "unknown"; - const usd = await budgetEventUsd(model, event); + // Prefer the credential's resolved accounting slug (set for DeepSeek runs) so usage is + // attributed to the model that actually served the run — not the Auto/default request. + const resolvedModelId = getRunStateValue(ctx, "resolved_model_id"); + const model = resolvedModelId ?? input.model ?? storedBeforeAppend?.modelId ?? "unknown"; + const isPlatformFree = getRunStateValue(ctx, "credit_source") === "platform_free"; + // Free DeepSeek runs are $0 to the user (metered by tokens, not USD), so they never + // consume the user's run/daily USD budget cap. + const usd = isPlatformFree ? 0 : await budgetEventUsd(model, event); appendBudgetEvent(ctx, { kind: event.kind, modelId: model, @@ -41,10 +48,13 @@ export async function recordBudgetDelta( }); const stored = readStoredRunSnapshot(ctx); const provider = providerFromModel(model); + const freeDeepseekTokens = + isPlatformFree && event.kind === "llm_usage" ? event.tokensIn + event.tokensOut : 0; ctx.waitUntil( persistAgentRunUsage(env, { costUsd: usd, eventType: event.kind, + ...(freeDeepseekTokens > 0 ? { freeDeepseekTokens } : {}), inputTokens: event.tokensIn, model, outputTokens: event.tokensOut, @@ -76,10 +86,13 @@ async function budgetEventUsd(model: string, event: BudgetDelta): Promise { + if (getRunStateValue(deps.ctx, "credit_source") !== "platform_free") { + return; + } + const projected = freeDeepseekStartUsed(deps.ctx) + snapshot.tokensIn + snapshot.tokensOut; + if (projected < FREE_DEEPSEEK_TOKEN_LIMIT) { + return; + } + await deps.append(freeDeepseekQuotaReachedChunk()); + if (isAnswerTextOpen) { + await deps.append({ id: "answer", type: "text-end" }); + } + await appendStoredBudgetStatus(deps, input); + await deps.append({ finishReason: "stop", type: "finish" }); + await deps.markCompleted(input); + deps.closeSubscribers(); + throw new APIError( + 402, + "deepseek_free_quota_exhausted", + "Free DeepSeek token allowance reached.", + { retriable: false }, + ); +} + +function freeDeepseekStartUsed(ctx: DurableObjectState): number { + const raw = getRunStateValue(ctx, "free_deepseek_start_used"); + if (raw === undefined) { + return 0; + } + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 0; +} + export function costCapExhaustion( input: StartRunInput, nextRunCostUsd: number, diff --git a/apps/agent-worker/src/durable-objects/agent-run-env.ts b/apps/agent-worker/src/durable-objects/agent-run-env.ts index 8a6b439..29f4311 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-env.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-env.ts @@ -4,6 +4,7 @@ import type { ProjectSandbox } from "./project-sandbox"; export interface AgentRunEnv extends AnalyticsBindings { COMPOSIO_API_KEY?: WorkerSecret; + DEEPSEEK_PLATFORM_API_KEY?: WorkerSecret; HYPERDRIVE: Hyperdrive; OUTPUT_DOWNLOAD_BASE_URL?: string; OUTPUT_DOWNLOAD_SIGNING_SECRET: string; diff --git a/apps/agent-worker/src/durable-objects/agent-run-mastra-stream.ts b/apps/agent-worker/src/durable-objects/agent-run-mastra-stream.ts index ab6eef0..197fcce 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-mastra-stream.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-mastra-stream.ts @@ -67,6 +67,7 @@ export async function runMastraStream(options: MastraStreamOptions): Promise ApprovalBroker; env: AgentRunEnv; hasPendingDecision: () => boolean; + persistResolvedCredential: (credential: LlmCredential) => void; setRunStage: (stage: string) => void; } @@ -98,6 +99,10 @@ async function streamMastraRun( params: StreamRunParams, credential: LlmCredential, ): Promise { + // Persist the resolved credit source + accounting slug + free-tier baseline to run-state + // (covers both the primary and OpenAI-fallback credentials) before any tokens stream, so + // per-step metering and the free-quota hard stop read the model that actually served. + deps.persistResolvedCredential(credential); await runMastraStream({ abortSignal: params.abortSignal, ...(params.agentContextNote === undefined ? {} : { agentContextNote: params.agentContextNote }), diff --git a/apps/agent-worker/src/durable-objects/agent-run.ts b/apps/agent-worker/src/durable-objects/agent-run.ts index 6142a8c..a875bbd 100644 --- a/apps/agent-worker/src/durable-objects/agent-run.ts +++ b/apps/agent-worker/src/durable-objects/agent-run.ts @@ -33,6 +33,7 @@ import { type BudgetAccountingDeps, costCapExhaustion, enforceCostCaps, + enforceFreeDeepseekCap, isCostCapAPIError, recordBudgetDelta, } from "./agent-run-cost-caps"; @@ -72,6 +73,7 @@ import { saveTakeoverStateInStorage, } from "./agent-run-takeover-state"; import { isAppBuilderRequest, missingInternalUserResponse } from "./agent-run-utils"; +import type { LlmCredential } from "./llm-provider"; import { mastraChunkError, mastraChunkToUiChunks, @@ -548,10 +550,30 @@ export class AgentRun extends DurableObject { createBroker: () => this.approvals.createBroker(), env: this.env, hasPendingDecision: () => this.approvals.hasPendingDecision(), + persistResolvedCredential: (credential) => this.persistResolvedCredential(credential), setRunStage: (stage) => this.setRunStage(stage), }; } + private persistResolvedCredential(credential: LlmCredential): void { + setRunStateValue(this.ctx, "credit_source", credential.creditSource); + // DeepSeek runs (free or BYOK) carry the bare provider id; persist the catalog/accounting + // slug so usage attribution matches the catalog format even for Auto-resolved runs. + if (credential.provider === "deepseek") { + setRunStateValue(this.ctx, "resolved_model_id", `deepseek/${credential.modelId}`); + } + if ( + credential.creditSource === "platform_free" && + credential.freeTokensUsedAtResolve !== undefined + ) { + setRunStateValue( + this.ctx, + "free_deepseek_start_used", + String(credential.freeTokensUsedAtResolve), + ); + } + } + private async appendMastraChunk(chunk: unknown): Promise { let appendedCount = 0; for (const uiChunk of mastraChunkToUiChunks(chunk)) { @@ -590,6 +612,7 @@ export class AgentRun extends DurableObject { usd: usage.costUsd ?? 0, }); await enforceCostCaps(this.accountingDeps(), input, snapshot, true); + await enforceFreeDeepseekCap(this.accountingDeps(), input, snapshot, true); } return appendedCount; } diff --git a/apps/agent-worker/src/durable-objects/llm-provider.ts b/apps/agent-worker/src/durable-objects/llm-provider.ts index 2b14120..4c6af19 100644 --- a/apps/agent-worker/src/durable-objects/llm-provider.ts +++ b/apps/agent-worker/src/durable-objects/llm-provider.ts @@ -1,26 +1,57 @@ import { + DEFAULT_DEEPSEEK_MODEL_ID, DEFAULT_OPENAI_MODEL_ID, type LlmModelSelection, type LlmProvider, resolveRequestedLlmModel, } from "@cheatcode/agent-core"; import { getProviderKey } from "@cheatcode/byok"; -import { createDb, type Database, type DatabaseHandle, withUserContext } from "@cheatcode/db"; +import { + createDb, + type Database, + type DatabaseHandle, + getFreeDeepseekUsage, + withUserContext, +} from "@cheatcode/db"; +import { resolveWorkerSecret, type WorkerSecret } from "@cheatcode/env"; import { APIError, type createLogger } from "@cheatcode/observability"; -import { UserId } from "@cheatcode/types"; +import { FREE_DEEPSEEK_MODEL_ID, UserId } from "@cheatcode/types"; import { closeDatabaseBestEffort } from "./db-close"; interface LlmProviderEnv { HYPERDRIVE: Hyperdrive; + DEEPSEEK_PLATFORM_API_KEY?: WorkerSecret; } interface LlmProviderInput { model?: string | undefined; userId: string; + /** Whether the user explicitly chose this model (vs an Auto/implicit default). */ + modelExplicit?: boolean | undefined; + /** Models the user disabled in settings; gates the free-DeepSeek fallback. */ + disabledModels?: readonly string[] | undefined; } +export type CreditSource = "byok" | "platform_free"; + export interface LlmCredential extends LlmModelSelection { apiKey: string; + creditSource: CreditSource; + /** For platform_free runs: the user's free-token count at resolution (DO hard-stop baseline). */ + freeTokensUsedAtResolve?: number; +} + +interface FreeTierContext { + platformDeepseekKey: string | undefined; + modelExplicit: boolean; + disabledModels: readonly string[]; +} + +interface ResolvedTransport { + apiKey: string; + selection: LlmModelSelection; + creditSource: CreditSource; + freeTokensUsed?: number; } export async function resolveLlmCredential( @@ -29,7 +60,12 @@ export async function resolveLlmCredential( logger: ReturnType, ): Promise { const selection = resolveModelSelection(input.model); - return resolveProviderKey(env, input.userId, selection, logger); + const platformDeepseekKey = await resolveWorkerSecret(env.DEEPSEEK_PLATFORM_API_KEY); + return resolveProviderKey(env, input.userId, selection, logger, { + disabledModels: input.disabledModels ?? [], + modelExplicit: input.modelExplicit ?? Boolean(input.model?.trim()), + platformDeepseekKey, + }); } export async function resolveOpenAiFallbackCredential( @@ -39,7 +75,12 @@ export async function resolveOpenAiFallbackCredential( ): Promise { const selection = { provider: "openai", modelId: DEFAULT_OPENAI_MODEL_ID } as const; try { - return await resolveProviderKey(env, input.userId, selection, logger); + // The OpenAI fallback never rides free DeepSeek credits (platform key withheld). + return await resolveProviderKey(env, input.userId, selection, logger, { + disabledModels: [], + modelExplicit: true, + platformDeepseekKey: undefined, + }); } catch (error) { if (error instanceof APIError && error.code === "byok_key_missing") { logger.warn("llm_provider_fallback_unavailable", { provider: "openai" }); @@ -106,7 +147,7 @@ function resolveModelSelection(model: string | undefined): LlmModelSelection { } catch (error) { throw new APIError(400, "invalid_request_body", "Unsupported model selection.", { details: { message: error instanceof Error ? error.message : "Unknown model error" }, - hint: "Use a supported Anthropic, Google Gemini, OpenAI, or OpenRouter model id.", + hint: "Use a supported Anthropic, Google Gemini, OpenAI, DeepSeek, or OpenRouter model id.", retriable: false, }); } @@ -117,49 +158,117 @@ async function resolveProviderKey( userId: string, selection: LlmModelSelection, logger: ReturnType, + freeTier: FreeTierContext, ): Promise { const dbHandle = createDb(env.HYPERDRIVE); + const brandedUserId = UserId(userId); try { - const resolved = await withUserContext(dbHandle.db, UserId(userId), (db) => - resolveTransportKey(db, selection), + const resolved = await withUserContext(dbHandle.db, brandedUserId, (db) => + resolveTransportKey(db, brandedUserId, selection, freeTier), ); logger.info("byok_provider_key_resolved", { + creditSource: resolved.creditSource, modelId: resolved.selection.modelId, provider: resolved.selection.provider, }); - return { ...resolved.selection, apiKey: resolved.apiKey }; + return { + ...resolved.selection, + apiKey: resolved.apiKey, + creditSource: resolved.creditSource, + ...(resolved.freeTokensUsed === undefined + ? {} + : { freeTokensUsedAtResolve: resolved.freeTokensUsed }), + }; } finally { await closeDatabase(dbHandle, logger); } } /** - * D9 transport rule: prefer the user's direct provider key; otherwise route a - * non-OpenRouter selection through OpenRouter (using the full `provider/model` - * slug) when an OpenRouter key is present; otherwise the model is unavailable. - * Runs inside the caller's already-open withUserContext connection — at most one - * extra indexed get_provider_key call on the direct-key miss path. + * Transport rule (plan §"Credential resolution"): (a) the user's direct provider key + * always wins. The platform free DeepSeek key then serves the `deepseek-v4-flash` SKU — + * before OpenRouter when the user explicitly picked it, or after OpenRouter as the last + * resort for an Auto/implicit run with no usable key. (c) Otherwise a non-OpenRouter + * selection routes through OpenRouter when a key is present. Runs inside the caller's + * withUserContext connection. The free SKU never silently downgrades a `deepseek-v4-pro` + * request, and an explicit non-free pick is never swapped to free credits. */ async function resolveTransportKey( db: Database, + userId: UserId, selection: LlmModelSelection, -): Promise<{ apiKey: string; selection: LlmModelSelection }> { + freeTier: FreeTierContext, +): Promise { + // (a) The user's own direct provider key always wins (incl. their own DeepSeek key). const directKey = await getProviderKey(db, selection.provider); if (directKey) { - return { apiKey: directKey, selection }; + return { apiKey: directKey, creditSource: "byok", selection }; } + + const wantsFreeFlash = + selection.provider === "deepseek" && selection.modelId === DEFAULT_DEEPSEEK_MODEL_ID; + const platformKey = freeTier.platformDeepseekKey; + const freeModelAllowed = + platformKey !== undefined && !freeTier.disabledModels.includes(FREE_DEEPSEEK_MODEL_ID); + // Memoize the allowance read so the gate costs at most one query per resolution. + let freeUsage: { limit: number; used: number } | undefined; + const tryPlatformFree = async (): Promise => { + if (!freeModelAllowed || platformKey === undefined) { + return null; + } + if (freeUsage === undefined) { + freeUsage = await getFreeDeepseekUsage(db, userId); + } + return freeUsage.used < freeUsage.limit + ? platformFreeTransport(platformKey, freeUsage.used) + : null; + }; + + // (b) Explicit free-flash pick → platform free before OpenRouter. + if (wantsFreeFlash) { + const free = await tryPlatformFree(); + if (free) { + return free; + } + } + + // (c) OpenRouter fallback (existing D9 rule). if (selection.provider !== "openrouter") { const openrouterKey = await getProviderKey(db, "openrouter"); if (openrouterKey) { return { apiKey: openrouterKey, + creditSource: "byok", selection: { modelId: openRouterSlug(selection), provider: "openrouter" }, }; } } + + // (d) Auto/implicit run with no usable key → platform free as a last resort. + if (!freeTier.modelExplicit) { + const free = await tryPlatformFree(); + if (free) { + return free; + } + } + + // The free path was attempted but the allowance is spent → a clear "used up" error + // (vs a generic missing-key error) so the user knows to add their own key. + if (freeUsage !== undefined && freeUsage.used >= freeUsage.limit) { + throw freeDeepseekQuotaExhausted(); + } throw missingProviderKey(selection.provider); } +function platformFreeTransport(apiKey: string, freeTokensUsed: number): ResolvedTransport { + return { + apiKey, + creditSource: "platform_free", + freeTokensUsed, + selection: { modelId: DEFAULT_DEEPSEEK_MODEL_ID, provider: "deepseek" }, + }; +} + function openRouterSlug(selection: LlmModelSelection): string { return `${selection.provider}/${selection.modelId}`; } @@ -182,9 +291,24 @@ function providerLabel(provider: LlmProvider): string { if (provider === "google") { return "Google Gemini"; } + if (provider === "deepseek") { + return "DeepSeek"; + } return "OpenRouter"; } +function freeDeepseekQuotaExhausted(): APIError { + return new APIError( + 402, + "deepseek_free_quota_exhausted", + "Your 200,000 free DeepSeek tokens are used up.", + { + hint: "Add your own DeepSeek (or Anthropic/OpenAI) key in Settings → Models to keep building.", + retriable: false, + }, + ); +} + function readStatusCode(error: unknown): number | null { if (!isRecord(error)) { return null; diff --git a/apps/agent-worker/wrangler.jsonc b/apps/agent-worker/wrangler.jsonc index 82cb063..785d9c0 100644 --- a/apps/agent-worker/wrangler.jsonc +++ b/apps/agent-worker/wrangler.jsonc @@ -34,6 +34,11 @@ "binding": "INTERNAL_MAINTENANCE_SECRET", "store_id": "ba25994718db4707ab99a498e22eb5a6", "secret_name": "internal-maintenance-secret" + }, + { + "binding": "DEEPSEEK_PLATFORM_API_KEY", + "store_id": "ba25994718db4707ab99a498e22eb5a6", + "secret_name": "deepseek-platform-api-key" } ], "durable_objects": { diff --git a/apps/gateway-worker/src/openapi-account-routes.ts b/apps/gateway-worker/src/openapi-account-routes.ts index cd105ad..eddbe8c 100644 --- a/apps/gateway-worker/src/openapi-account-routes.ts +++ b/apps/gateway-worker/src/openapi-account-routes.ts @@ -22,7 +22,8 @@ const nullableCatalogModelSchema = (): JsonValue => ({ }); const disabledModelsSchema = (): JsonValue => ({ items: catalogModelSchema(), - maxItems: 3, + // One fewer than the catalog so ≥1 model always stays enabled (mirrors the zod schema). + maxItems: AGENT_MODEL_CATALOG.length - 1, type: "array", }); const onboardingStateSchema: JsonValue = { @@ -46,6 +47,15 @@ const onboardingStepSchema: JsonValue = { required: ["status", "step"], type: "object", }; +const freeDeepseekSchema: JsonValue = { + additionalProperties: false, + properties: { + limit: { minimum: 1, type: "integer" }, + used: { minimum: 0, type: "integer" }, + }, + required: ["limit", "used"], + type: "object", +}; export const accountSchemas: Record = { UpdateUserProfile: { @@ -73,6 +83,7 @@ export const accountSchemas: Record = { disabledModels: disabledModelsSchema(), generalDefaultBudgetUsd: nullableNumberSchema({ exclusiveMinimum: 0, maximum: 50 }), generalDefaultModel: nullableCatalogModelSchema(), + freeDeepseek: freeDeepseekSchema, globalMemory: nullableStringSchema({ maxLength: 8_000 }), onboardingCompletedAt: nullableStringSchema({ format: "date-time" }), onboardingState: onboardingStateSchema, diff --git a/apps/gateway-worker/src/profile-routes.ts b/apps/gateway-worker/src/profile-routes.ts index 59716b0..ff89b23 100644 --- a/apps/gateway-worker/src/profile-routes.ts +++ b/apps/gateway-worker/src/profile-routes.ts @@ -1,6 +1,8 @@ import { updateClerkUserPublicMetadata, verifyClerkBearerToken } from "@cheatcode/auth"; import { createDb, + type FreeDeepseekUsage, + getFreeDeepseekUsage, getUserProfile, type UpsertUserProfileInput, type UserProfileRecord, @@ -31,8 +33,11 @@ export async function getMyProfileRoute( ): Promise { const { db, close } = createDb(env.HYPERDRIVE); try { - const record = await withUserContext(db, userId, (tx) => getUserProfile(tx, userId)); - return Response.json(UserProfileSchema.parse(profileResponse(record))); + const { freeDeepseek, record } = await withUserContext(db, userId, async (tx) => ({ + freeDeepseek: await getFreeDeepseekUsage(tx, userId), + record: await getUserProfile(tx, userId), + })); + return Response.json(UserProfileSchema.parse(profileResponse(record, freeDeepseek))); } finally { ctx.waitUntil(close()); } @@ -160,13 +165,19 @@ async function mirrorOnboardingClaim( } } -function profileResponse(record: UserProfileRecord | null): Record { +function profileResponse( + record: UserProfileRecord | null, + freeDeepseek?: FreeDeepseekUsage, +): Record { + // Optional so the update route can omit it (the meter is read from the GET response). + const freeDeepseekField = freeDeepseek ? { freeDeepseek } : {}; if (!record) { return { agentDisplayName: null, appbuilderDefaultBudgetUsd: null, appbuilderDefaultModel: null, disabledModels: [], + ...freeDeepseekField, generalDefaultBudgetUsd: null, generalDefaultModel: null, globalMemory: null, @@ -180,6 +191,7 @@ function profileResponse(record: UserProfileRecord | null): Record
Always on } - description="Routes each run to the best enabled model for the task." + description="Routes each run to the best available model for the task." label="Auto" /> - {AGENT_MODEL_CATALOG.map((model) => { + {orderModelsFreeFirst().map((model) => { const enabled = !disabledModels.includes(model.id); + const isFree = model.id === FREE_DEEPSEEK_MODEL_ID; return ( toggleModel(model.id, !enabled)} /> } - description={model.description} + description={modelSourceLabel(model)} + extra={ + isFree && freeDeepseek ? ( + + ) : null + } key={model.id} label={model.label} /> @@ -67,13 +81,36 @@ export function AgentsPanel() { ); } +/** The free DeepSeek model renders first; the rest keep their catalog order. */ +function orderModelsFreeFirst(): readonly CatalogModel[] { + return [ + ...AGENT_MODEL_CATALOG.filter((model) => model.id === FREE_DEEPSEEK_MODEL_ID), + ...AGENT_MODEL_CATALOG.filter((model) => model.id !== FREE_DEEPSEEK_MODEL_ID), + ]; +} + +function modelSourceLabel(model: CatalogModel): string { + if (model.id === FREE_DEEPSEEK_MODEL_ID) { + return "via free credits"; + } + if (model.id === FALLBACK_MODEL_ID) { + return "fallback"; + } + if (model.provider === "anthropic") { + return "via your Anthropic key"; + } + return "via your OpenAI key"; +} + function ModelRow({ control, description, + extra, label, }: { control: ReactNode; description: string; + extra?: ReactNode; label: string; }) { return ( @@ -81,12 +118,35 @@ function ModelRow({
{label}

{description}

+ {extra}
{control}
); } +function FreeCreditMeter({ limit, used }: { limit: number; used: number }) { + const pct = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0; + const remaining = Math.max(0, limit - used); + return ( +
+
+
0 ? "bg-purple-500" : "bg-red-500")} + style={{ width: `${pct}%` }} + /> +
+

+ {formatTokens(used)} of {formatTokens(limit)} free tokens used +

+
+ ); +} + +function formatTokens(value: number): string { + return value >= 1000 ? `${Math.round(value / 1000)}K` : String(value); +} + function ModelToggle({ disabled, enabled, diff --git a/apps/web/src/components/settings/provider-keys-panel.tsx b/apps/web/src/components/settings/provider-keys-panel.tsx index bdcff2a..e039a7b 100644 --- a/apps/web/src/components/settings/provider-keys-panel.tsx +++ b/apps/web/src/components/settings/provider-keys-panel.tsx @@ -49,6 +49,13 @@ const PROVIDER_META: Record = { label: "Anthropic", placeholder: "sk-ant-...", }, + deepseek: { + description: "Your own DeepSeek key — runs beyond the free 200K token allowance.", + keyUrl: "https://platform.deepseek.com/api_keys", + keyUrlLabel: "platform.deepseek.com", + label: "DeepSeek", + placeholder: "sk-...", + }, elevenlabs: { description: "Voice generation and speech APIs for media workflows.", keyUrl: "https://elevenlabs.io/app/settings/api-keys", diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index aade934..a82036a 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@ai-sdk/anthropic": "catalog:", + "@ai-sdk/deepseek": "catalog:", "@ai-sdk/google": "catalog:", "@ai-sdk/openai": "catalog:", "@cheatcode/observability": "workspace:*", diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 3a31f1f..4641e59 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -3,10 +3,13 @@ export type { LlmModelSelection, LlmProvider } from "./mastra/agents"; export { ANTHROPIC_API_KEY_CONTEXT_KEY, createAnthropicByokModel, + createDeepSeekModel, createGoogleByokModel, createOpenAiByokModel, createOpenRouterByokModel, + DEEPSEEK_API_KEY_CONTEXT_KEY, DEFAULT_ANTHROPIC_MODEL_ID, + DEFAULT_DEEPSEEK_MODEL_ID, DEFAULT_GOOGLE_MODEL_ID, DEFAULT_OPENAI_MODEL_ID, DEFAULT_OPENROUTER_MODEL_ID, diff --git a/packages/agent-core/src/mastra/agents/general.ts b/packages/agent-core/src/mastra/agents/general.ts index 543a201..007e4a0 100644 --- a/packages/agent-core/src/mastra/agents/general.ts +++ b/packages/agent-core/src/mastra/agents/general.ts @@ -1,4 +1,5 @@ import { createAnthropic } from "@ai-sdk/anthropic"; +import { createDeepSeek } from "@ai-sdk/deepseek"; import { createGoogleGenerativeAI } from "@ai-sdk/google"; import { createOpenAI } from "@ai-sdk/openai"; import { APIError } from "@cheatcode/observability"; @@ -8,7 +9,9 @@ import type { RequestContext } from "@mastra/core/request-context"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { ANTHROPIC_API_KEY_CONTEXT_KEY, + DEEPSEEK_API_KEY_CONTEXT_KEY, DEFAULT_ANTHROPIC_MODEL_ID, + DEFAULT_DEEPSEEK_MODEL_ID, DEFAULT_GOOGLE_MODEL_ID, DEFAULT_OPENAI_MODEL_ID, DEFAULT_OPENROUTER_MODEL_ID, @@ -26,7 +29,9 @@ import { cheatcodeTools } from "../tools/tool-set"; export type { LlmModelSelection, LlmProvider } from "../llm-context"; export { ANTHROPIC_API_KEY_CONTEXT_KEY, + DEEPSEEK_API_KEY_CONTEXT_KEY, DEFAULT_ANTHROPIC_MODEL_ID, + DEFAULT_DEEPSEEK_MODEL_ID, DEFAULT_GOOGLE_MODEL_ID, DEFAULT_OPENAI_MODEL_ID, DEFAULT_OPENROUTER_MODEL_ID, @@ -86,6 +91,22 @@ export function createOpenRouterByokModel( }).chat(modelId); } +/** + * DeepSeek model — serves both the platform free tier (our key) and user BYOK keys. + * Pass the bare provider id (`deepseek-v4-flash`); non-thinking mode is selected at the + * stream call site via providerOptions so tool-calling stays clean. + */ +export function createDeepSeekModel( + apiKey: string, + modelId = DEFAULT_DEEPSEEK_MODEL_ID, +): MastraModelConfig { + const trimmed = apiKey.trim(); + if (trimmed.length === 0) { + throw new Error("DeepSeek key is required."); + } + return createDeepSeek({ apiKey: trimmed })(modelId); +} + export function resolveRequestedLlmModel(model: string | null | undefined): LlmModelSelection { const requested = model?.trim(); if (!requested) { @@ -104,6 +125,9 @@ export function resolveRequestedLlmModel(model: string | null | undefined): LlmM if (requested.startsWith("openrouter/")) { return requestedModel("openrouter", requested.slice("openrouter/".length)); } + if (requested.startsWith("deepseek/")) { + return requestedModel("deepseek", requested.slice("deepseek/".length)); + } if (requested.startsWith("claude-")) { return requestedModel("anthropic", requested); } @@ -113,6 +137,9 @@ export function resolveRequestedLlmModel(model: string | null | undefined): LlmM if (requested.startsWith("gemini-")) { return requestedModel("google", requested); } + if (requested.startsWith("deepseek-")) { + return requestedModel("deepseek", requested); + } throw new Error(`Unsupported model selection: ${requested}`); } @@ -149,6 +176,11 @@ function resolveGeneralModel({ requiredProviderKey(requestContext, GOOGLE_API_KEY_CONTEXT_KEY, "Google Gemini", provider), requestedModelId(modelId, DEFAULT_GOOGLE_MODEL_ID), ); + case "deepseek": + return createDeepSeekModel( + requiredProviderKey(requestContext, DEEPSEEK_API_KEY_CONTEXT_KEY, "DeepSeek", provider), + requestedModelId(modelId, DEFAULT_DEEPSEEK_MODEL_ID), + ); case "anthropic": return createAnthropicByokModel( requiredProviderKey(requestContext, ANTHROPIC_API_KEY_CONTEXT_KEY, "Anthropic", provider), @@ -158,7 +190,7 @@ function resolveGeneralModel({ } function resolveLlmProvider(value: unknown): LlmProvider { - if (value === "google" || value === "openai" || value === "openrouter") { + if (value === "google" || value === "openai" || value === "openrouter" || value === "deepseek") { return value; } return "anthropic"; diff --git a/packages/agent-core/src/mastra/agents/index.ts b/packages/agent-core/src/mastra/agents/index.ts index 8991705..d68884f 100644 --- a/packages/agent-core/src/mastra/agents/index.ts +++ b/packages/agent-core/src/mastra/agents/index.ts @@ -2,10 +2,13 @@ export type { LlmModelSelection, LlmProvider } from "./general"; export { ANTHROPIC_API_KEY_CONTEXT_KEY, createAnthropicByokModel, + createDeepSeekModel, createGoogleByokModel, createOpenAiByokModel, createOpenRouterByokModel, + DEEPSEEK_API_KEY_CONTEXT_KEY, DEFAULT_ANTHROPIC_MODEL_ID, + DEFAULT_DEEPSEEK_MODEL_ID, DEFAULT_GOOGLE_MODEL_ID, DEFAULT_OPENAI_MODEL_ID, DEFAULT_OPENROUTER_MODEL_ID, diff --git a/packages/agent-core/src/mastra/llm-context.ts b/packages/agent-core/src/mastra/llm-context.ts index 4739c60..d5bdd09 100644 --- a/packages/agent-core/src/mastra/llm-context.ts +++ b/packages/agent-core/src/mastra/llm-context.ts @@ -2,14 +2,16 @@ export const ANTHROPIC_API_KEY_CONTEXT_KEY = "anthropicApiKey"; export const GOOGLE_API_KEY_CONTEXT_KEY = "googleApiKey"; export const OPENAI_API_KEY_CONTEXT_KEY = "openaiApiKey"; export const OPENROUTER_API_KEY_CONTEXT_KEY = "openrouterApiKey"; +export const DEEPSEEK_API_KEY_CONTEXT_KEY = "deepseekApiKey"; export const LLM_MODEL_ID_CONTEXT_KEY = "llmModelId"; export const LLM_PROVIDER_CONTEXT_KEY = "llmProvider"; export const DEFAULT_ANTHROPIC_MODEL_ID = "claude-sonnet-4-6"; export const DEFAULT_GOOGLE_MODEL_ID = "gemini-2.5-flash"; export const DEFAULT_OPENAI_MODEL_ID = "gpt-5.4-mini"; export const DEFAULT_OPENROUTER_MODEL_ID = "openrouter/auto"; +export const DEFAULT_DEEPSEEK_MODEL_ID = "deepseek-v4-flash"; -export type LlmProvider = "anthropic" | "google" | "openai" | "openrouter"; +export type LlmProvider = "anthropic" | "google" | "openai" | "openrouter" | "deepseek"; export interface LlmModelSelection { provider: LlmProvider; diff --git a/packages/agent-core/src/mastra/tools/browser-runtime.ts b/packages/agent-core/src/mastra/tools/browser-runtime.ts index 0517e20..d40e7ab 100644 --- a/packages/agent-core/src/mastra/tools/browser-runtime.ts +++ b/packages/agent-core/src/mastra/tools/browser-runtime.ts @@ -54,15 +54,29 @@ function browserCredentialFromRequestContext(requestContext: RequestContextReade ); } + // Fallback for non-vision providers (e.g. a DeepSeek-free or OpenRouter run): browser + // tools need a vision/CUA key, so prefer any the user has. The platform DeepSeek key is + // never read here, so it can never reach the sandbox as a browser credential. const anthropicKey = requestContext.get(ANTHROPIC_API_KEY_CONTEXT_KEY); if (typeof anthropicKey === "string" && anthropicKey.trim().length > 0) { return providerCredential("anthropic", anthropicKey, undefined, DEFAULT_ANTHROPIC_MODEL_ID); } - return providerCredential( - "openai", - requestContext.get(OPENAI_API_KEY_CONTEXT_KEY), - undefined, - DEFAULT_OPENAI_MODEL_ID, + const openaiKey = requestContext.get(OPENAI_API_KEY_CONTEXT_KEY); + if (typeof openaiKey === "string" && openaiKey.trim().length > 0) { + return providerCredential("openai", openaiKey, undefined, DEFAULT_OPENAI_MODEL_ID); + } + const googleKey = requestContext.get(GOOGLE_API_KEY_CONTEXT_KEY); + if (typeof googleKey === "string" && googleKey.trim().length > 0) { + return providerCredential("google", googleKey, undefined, DEFAULT_GOOGLE_MODEL_ID); + } + throw new APIError( + 400, + "byok_key_missing", + "Browser automation needs an Anthropic, OpenAI, or Google API key.", + { + hint: "Add one in Settings → Models. Free DeepSeek credits don't cover browser tools.", + retriable: false, + }, ); } diff --git a/packages/agent-core/src/mastra/tools/run-code-execution.ts b/packages/agent-core/src/mastra/tools/run-code-execution.ts index 15226ad..f60ab08 100644 --- a/packages/agent-core/src/mastra/tools/run-code-execution.ts +++ b/packages/agent-core/src/mastra/tools/run-code-execution.ts @@ -12,6 +12,7 @@ import { } from "../composio-context"; import { ANTHROPIC_API_KEY_CONTEXT_KEY, + DEEPSEEK_API_KEY_CONTEXT_KEY, GOOGLE_API_KEY_CONTEXT_KEY, LLM_MODEL_ID_CONTEXT_KEY, LLM_PROVIDER_CONTEXT_KEY, @@ -41,6 +42,7 @@ export interface CodeRequestContextOptions { composioConnectedAccounts?: ComposioConnectedAccounts | undefined; composioQuotaMeter?: ComposioQuotaMeter | undefined; composioUserId?: string | undefined; + deepseekApiKey?: string | undefined; elevenlabsApiKey?: string | undefined; exaApiKey?: string | undefined; falApiKey?: string | undefined; @@ -84,6 +86,7 @@ function contextEntries( [OPENAI_API_KEY_CONTEXT_KEY, options.openaiApiKey], [GOOGLE_API_KEY_CONTEXT_KEY, options.googleApiKey], [OPENROUTER_API_KEY_CONTEXT_KEY, options.openrouterApiKey], + [DEEPSEEK_API_KEY_CONTEXT_KEY, options.deepseekApiKey], [EXA_API_KEY_CONTEXT_KEY, options.exaApiKey], [FIRECRAWL_API_KEY_CONTEXT_KEY, options.firecrawlApiKey], [RESEARCH_FANOUT_SUBAGENT_LIMIT_CONTEXT_KEY, options.researchFanoutSubagentLimit], diff --git a/packages/byok/src/provider-validation.ts b/packages/byok/src/provider-validation.ts index 54dbd94..0413067 100644 --- a/packages/byok/src/provider-validation.ts +++ b/packages/byok/src/provider-validation.ts @@ -17,6 +17,17 @@ const PROVIDER_VALIDATORS = { "x-api-key": key, }), }, + deepseek: { + invalidStatuses: [401, 403], + label: "DeepSeek", + method: "GET", + schema: z.object({ data: z.array(z.object({ id: z.string().min(1) }).passthrough()) }), + url: "https://api.deepseek.com/models", + headers: (key: string) => ({ + authorization: `Bearer ${key}`, + "content-type": "application/json", + }), + }, elevenlabs: { invalidStatuses: [401, 403], label: "ElevenLabs", diff --git a/packages/db/drizzle/0004_amused_steve_rogers.sql b/packages/db/drizzle/0004_amused_steve_rogers.sql new file mode 100644 index 0000000..b4ea6e6 --- /dev/null +++ b/packages/db/drizzle/0004_amused_steve_rogers.sql @@ -0,0 +1 @@ +ALTER TABLE "v2_entitlements" ADD COLUMN "free_deepseek_tokens_used" bigint DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0004_snapshot.json b/packages/db/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..9065235 --- /dev/null +++ b/packages/db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1266 @@ +{ + "id": "95dbb96a-d391-4837-a06f-e17ad57fbb2d", + "prevId": "4f8d8b71-c368-4b58-9eff-1cac51a97df8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.v2_agent_runs": { + "name": "v2_agent_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "public.uuidv7()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens_cached": { + "name": "tokens_cached", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "v2_agent_runs_thread_id_v2_threads_id_fk": { + "name": "v2_agent_runs_thread_id_v2_threads_id_fk", + "tableFrom": "v2_agent_runs", + "tableTo": "v2_threads", + "columnsFrom": ["thread_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_agent_runs_user_id_v2_users_id_fk": { + "name": "v2_agent_runs_user_id_v2_users_id_fk", + "tableFrom": "v2_agent_runs", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_billing_events": { + "name": "v2_billing_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "public.uuidv7()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "polar_event_id": { + "name": "polar_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "v2_billing_events_user_id_v2_users_id_fk": { + "name": "v2_billing_events_user_id_v2_users_id_fk", + "tableFrom": "v2_billing_events", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_billing_events_polar_event_id_unique": { + "name": "v2_billing_events_polar_event_id_unique", + "nullsNotDistinct": false, + "columns": ["polar_event_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_entitlements": { + "name": "v2_entitlements", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "polar_customer_id": { + "name": "polar_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "polar_subscription_id": { + "name": "polar_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "max_projects": { + "name": "max_projects", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "max_concurrent_sandboxes": { + "name": "max_concurrent_sandboxes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "max_seats": { + "name": "max_seats", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "quota_sandbox_hours": { + "name": "quota_sandbox_hours", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'5'" + }, + "quota_composio_calls": { + "name": "quota_composio_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1000 + }, + "quota_deployments": { + "name": "quota_deployments", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "free_deepseek_tokens_used": { + "name": "free_deepseek_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "flag_private_projects": { + "name": "flag_private_projects", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "flag_sso": { + "name": "flag_sso", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "webhook_event_id": { + "name": "webhook_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "v2_entitlements_user_id_v2_users_id_fk": { + "name": "v2_entitlements_user_id_v2_users_id_fk", + "tableFrom": "v2_entitlements", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "v2_entitlements_tier_check": { + "name": "v2_entitlements_tier_check", + "value": "\"v2_entitlements\".\"tier\" in ('free','pro','premium','ultra','max')" + } + }, + "isRLSEnabled": false + }, + "public.v2_generated_outputs": { + "name": "v2_generated_outputs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "public.uuidv7()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "r2_bucket": { + "name": "r2_bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cheatcode-outputs'" + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "v2_generated_outputs_user_id_v2_users_id_fk": { + "name": "v2_generated_outputs_user_id_v2_users_id_fk", + "tableFrom": "v2_generated_outputs", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_generated_outputs_project_id_v2_projects_id_fk": { + "name": "v2_generated_outputs_project_id_v2_projects_id_fk", + "tableFrom": "v2_generated_outputs", + "tableTo": "v2_projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_generated_outputs_agent_run_id_v2_agent_runs_id_fk": { + "name": "v2_generated_outputs_agent_run_id_v2_agent_runs_id_fk", + "tableFrom": "v2_generated_outputs", + "tableTo": "v2_agent_runs", + "columnsFrom": ["agent_run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_messages": { + "name": "v2_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "public.uuidv7()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "v2_messages_thread_id_v2_threads_id_fk": { + "name": "v2_messages_thread_id_v2_threads_id_fk", + "tableFrom": "v2_messages", + "tableTo": "v2_threads", + "columnsFrom": ["thread_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_messages_user_id_v2_users_id_fk": { + "name": "v2_messages_user_id_v2_users_id_fk", + "tableFrom": "v2_messages", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_projects": { + "name": "v2_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "public.uuidv7()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "master_instructions": { + "name": "master_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "container_backup": { + "name": "container_backup", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "over_quota": { + "name": "over_quota", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_pending_action": { + "name": "archived_pending_action", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archive_after": { + "name": "archive_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "v2_projects_user_id_v2_users_id_fk": { + "name": "v2_projects_user_id_v2_users_id_fk", + "tableFrom": "v2_projects", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_provider_keys": { + "name": "v2_provider_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "public.uuidv7()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_secret_id": { + "name": "vault_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "v2_provider_keys_user_id_v2_users_id_fk": { + "name": "v2_provider_keys_user_id_v2_users_id_fk", + "tableFrom": "v2_provider_keys", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_threads": { + "name": "v2_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "public.uuidv7()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_run_id": { + "name": "active_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "v2_threads_project_id_v2_projects_id_fk": { + "name": "v2_threads_project_id_v2_projects_id_fk", + "tableFrom": "v2_threads", + "tableTo": "v2_projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_threads_user_id_v2_users_id_fk": { + "name": "v2_threads_user_id_v2_users_id_fk", + "tableFrom": "v2_threads", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_usage_daily_totals": { + "name": "v2_usage_daily_totals", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_tokens": { + "name": "total_cached_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_usd": { + "name": "total_cost_usd", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "agent_run_count": { + "name": "agent_run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "v2_usage_daily_totals_user_id_v2_users_id_fk": { + "name": "v2_usage_daily_totals_user_id_v2_users_id_fk", + "tableFrom": "v2_usage_daily_totals", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_usage_daily_totals_user_id_day_pk": { + "name": "v2_usage_daily_totals_user_id_day_pk", + "columns": ["user_id", "day"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_usage_events": { + "name": "v2_usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "public.uuidv7()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_tokens": { + "name": "cached_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "v2_usage_events_user_id_v2_users_id_fk": { + "name": "v2_usage_events_user_id_v2_users_id_fk", + "tableFrom": "v2_usage_events", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_user_integrations": { + "name": "v2_user_integrations", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "composio_connection_id": { + "name": "composio_connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "v2_user_integrations_user_id_v2_users_id_fk": { + "name": "v2_user_integrations_user_id_v2_users_id_fk", + "tableFrom": "v2_user_integrations", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_user_integrations_user_id_integration_pk": { + "name": "v2_user_integrations_user_id_integration_pk", + "columns": ["user_id", "integration"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_user_profiles": { + "name": "v2_user_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "agent_display_name": { + "name": "agent_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "global_memory": { + "name": "global_memory", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "appbuilder_default_model": { + "name": "appbuilder_default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "general_default_model": { + "name": "general_default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "appbuilder_default_budget_usd": { + "name": "appbuilder_default_budget_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "general_default_budget_usd": { + "name": "general_default_budget_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "disabled_models": { + "name": "disabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "onboarding_state": { + "name": "onboarding_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "v2_user_profiles_user_id_v2_users_id_fk": { + "name": "v2_user_profiles_user_id_v2_users_id_fk", + "tableFrom": "v2_user_profiles", + "tableTo": "v2_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_users": { + "name": "v2_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "public.uuidv7()" + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "polar_customer_id": { + "name": "polar_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_users_clerk_id_unique": { + "name": "v2_users_clerk_id_unique", + "nullsNotDistinct": false, + "columns": ["clerk_id"] + }, + "v2_users_polar_customer_id_unique": { + "name": "v2_users_polar_customer_id_unique", + "nullsNotDistinct": false, + "columns": ["polar_customer_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 8a716a9..554b460 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1781353364655, "tag": "0003_v2_billing_tier_premium_ultra_max", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1782027070753, + "tag": "0004_amused_steve_rogers", + "breakpoints": true } ] } diff --git a/packages/db/src/billing.ts b/packages/db/src/billing.ts index 36acb8e..626a116 100644 --- a/packages/db/src/billing.ts +++ b/packages/db/src/billing.ts @@ -1,5 +1,5 @@ import type { UserId } from "@cheatcode/types"; -import { UserId as toUserId } from "@cheatcode/types"; +import { FREE_DEEPSEEK_TOKEN_LIMIT, UserId as toUserId } from "@cheatcode/types"; import { and, eq, isNull, sql } from "drizzle-orm"; import type { Database } from "./client"; import { billingEvents, entitlements, users } from "./schema"; @@ -101,6 +101,29 @@ export async function findBillingUserByPolarCustomerId( : null; } +export interface FreeDeepseekUsage { + limit: number; + used: number; +} + +/** Reads the per-user lifetime free DeepSeek token counter from the entitlements row. */ +export async function getFreeDeepseekUsage( + db: Database, + userId: UserId, +): Promise { + const row = await db.query.entitlements.findFirst({ + columns: { freeDeepseekTokensUsed: true }, + where: eq(entitlements.userId, userId), + }); + return { limit: FREE_DEEPSEEK_TOKEN_LIMIT, used: row?.freeDeepseekTokensUsed ?? 0 }; +} + +/** True while the user still has free DeepSeek allowance left (run-start gate). */ +export async function hasFreeDeepseekAllowance(db: Database, userId: UserId): Promise { + const { limit, used } = await getFreeDeepseekUsage(db, userId); + return used < limit; +} + export async function findEntitlementByUserId( db: Database, userId: UserId, diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 8e58e5c..1faddf2 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -4,11 +4,14 @@ export type { EntitlementRecord, EntitlementSubscriptionStateInput, EntitlementUpsertInput, + FreeDeepseekUsage, } from "./billing"; export { findBillingUserById, findBillingUserByPolarCustomerId, findEntitlementByUserId, + getFreeDeepseekUsage, + hasFreeDeepseekAllowance, recordBillingEvent, updateEntitlementSubscriptionState, updateUserPolarCustomerId, diff --git a/packages/db/src/runs.ts b/packages/db/src/runs.ts index adc86f5..c5bf0d6 100644 --- a/packages/db/src/runs.ts +++ b/packages/db/src/runs.ts @@ -14,6 +14,7 @@ import { type AgentRunConfig, type AgentRunError, agentRuns, + entitlements, type ProjectSettings, projects, threads, @@ -69,6 +70,8 @@ export interface RecordAgentRunUsageInput { agentRunId: AgentRunId; costUsd: number; eventType: string; + /** When set (platform_free DeepSeek runs), meter these tokens against the lifetime allowance. */ + freeDeepseekTokens?: number; inputTokens: number; model?: string; outputTokens: number; @@ -504,6 +507,18 @@ export async function recordAgentRunUsage( }) .where(and(eq(agentRuns.id, input.agentRunId), eq(agentRuns.userId, input.userId))) .returning({ id: agentRuns.id }); + + // Meter platform-credited DeepSeek tokens against the per-user lifetime allowance. + // Atomic row-locked `+=` keyed by user, in the SAME tx as the run-total update, so the + // counter tracks agent_runs.tokens_* exactly (per-step delta, counted once) — plan WS3. + if (input.freeDeepseekTokens && input.freeDeepseekTokens > 0) { + await tx + .update(entitlements) + .set({ + freeDeepseekTokensUsed: sql`${entitlements.freeDeepseekTokensUsed} + ${input.freeDeepseekTokens}`, + }) + .where(eq(entitlements.userId, input.userId)); + } return Boolean(updatedRows[0]); }); } diff --git a/packages/db/src/schema/billing.ts b/packages/db/src/schema/billing.ts index 76a5579..8db4c4f 100644 --- a/packages/db/src/schema/billing.ts +++ b/packages/db/src/schema/billing.ts @@ -1,5 +1,6 @@ import { sql } from "drizzle-orm"; import { + bigint, boolean, check, integer, @@ -32,6 +33,11 @@ export const entitlements = pgTable( quotaSandboxHours: numeric("quota_sandbox_hours").notNull().default("5"), quotaComposioCalls: integer("quota_composio_calls").notNull().default(1000), quotaDeployments: integer("quota_deployments").notNull().default(5), + // Lifetime free DeepSeek token allowance: consumed counter (limit is a code constant, + // FREE_DEEPSEEK_TOKEN_LIMIT). Defaults to 0 so every new account has the full grant. + freeDeepseekTokensUsed: bigint("free_deepseek_tokens_used", { mode: "number" }) + .notNull() + .default(0), flagPrivateProjects: boolean("flag_private_projects").notNull().default(false), flagSso: boolean("flag_sso").notNull().default(false), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/env/src/worker.ts b/packages/env/src/worker.ts index 912b041..0454ab8 100644 --- a/packages/env/src/worker.ts +++ b/packages/env/src/worker.ts @@ -73,6 +73,9 @@ export const AgentWorkerEnvSchema = z BLAXEL_SANDBOX_IMAGE: z.string().min(1).optional(), BLAXEL_SANDBOX_MEMORY_MB: z.string().regex(/^\d+$/).optional(), COMPOSIO_API_KEY: OptionalWorkerSecretSchema, + // Platform-provided DeepSeek key for the free tier (Secrets-Store-bound, resolved + // request-scoped in the AgentRun DO via resolveWorkerSecret()). + DEEPSEEK_PLATFORM_API_KEY: OptionalWorkerSecretSchema, HYPERDRIVE: HyperdriveSchema, INTERNAL_MAINTENANCE_SECRET: OptionalWorkerSecretSchema, OUTPUT_DOWNLOAD_BASE_URL: z.string().url().optional(), diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 4ffe67a..707b3ea 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -123,6 +123,7 @@ export const ProviderSchema = z.enum([ "openai", "google", "openrouter", + "deepseek", "fal", "elevenlabs", "exa", diff --git a/packages/types/src/errors.ts b/packages/types/src/errors.ts index 2552379..07092ba 100644 --- a/packages/types/src/errors.ts +++ b/packages/types/src/errors.ts @@ -36,6 +36,7 @@ export type ErrorCode = | "byok_key_missing" | "byok_key_invalid" | "byok_key_quota_exhausted" + | "deepseek_free_quota_exhausted" | "sandbox_disk_full" | "sandbox_cpu_exhausted" | "sandbox_failed_to_start" diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 55a8d5b..c0cd4a9 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -132,6 +132,8 @@ export { AGENT_MODEL_CATALOG, CatalogModelIdSchema, FALLBACK_MODEL_ID, + FREE_DEEPSEEK_MODEL_ID, + FREE_DEEPSEEK_TOKEN_LIMIT, isCatalogModelId, PRODUCTION_DEFAULT_MODEL_ID, } from "./models"; diff --git a/packages/types/src/models.ts b/packages/types/src/models.ts index b069d17..1b02ea5 100644 --- a/packages/types/src/models.ts +++ b/packages/types/src/models.ts @@ -37,6 +37,12 @@ export const AGENT_MODEL_CATALOG = [ provider: "openai", description: "Fast fallback model for lower-cost utility runs.", }, + { + id: "deepseek/deepseek-v4-flash", + label: "DeepSeek V4", + provider: "deepseek", + description: "Free for everyone — up to 200K tokens included.", + }, ] as const; export type CatalogModelId = (typeof AGENT_MODEL_CATALOG)[number]["id"]; @@ -44,6 +50,15 @@ export type CatalogModelId = (typeof AGENT_MODEL_CATALOG)[number]["id"]; export const PRODUCTION_DEFAULT_MODEL_ID = "anthropic/claude-sonnet-4-6" satisfies CatalogModelId; export const FALLBACK_MODEL_ID = "openai/gpt-5.4-mini" satisfies CatalogModelId; +/** + * The free, platform-credited DeepSeek SKU. Zero-config default for keyless users + * and the only model the platform DeepSeek key serves (the `deepseek-v4-flash` + * provider id, prefixed with `deepseek/` as the catalog/accounting slug). + */ +export const FREE_DEEPSEEK_MODEL_ID = "deepseek/deepseek-v4-flash" satisfies CatalogModelId; +/** Lifetime free-token allowance per user for the platform-provided DeepSeek key. */ +export const FREE_DEEPSEEK_TOKEN_LIMIT = 200_000; + const CATALOG_MODEL_IDS = AGENT_MODEL_CATALOG.map((entry) => entry.id) as [ CatalogModelId, ...CatalogModelId[], diff --git a/packages/types/src/profile.ts b/packages/types/src/profile.ts index 2153c26..b5a94c9 100644 --- a/packages/types/src/profile.ts +++ b/packages/types/src/profile.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { CatalogModelIdSchema } from "./models"; +import { AGENT_MODEL_CATALOG, CatalogModelIdSchema } from "./models"; export const OnboardingStepSchema = z.enum(["intro", "name", "tools", "basics", "plan"]); export const OnboardingStepStatusSchema = z.enum(["done", "skipped"]); @@ -10,8 +10,9 @@ export const OnboardingStateSchema = z }) .strict(); -// `disabledModels.max(3)` encodes "≥1 catalog model stays enabled" structurally (catalog has 4 entries). -const DisabledModelsSchema = z.array(CatalogModelIdSchema).max(3); +// Cap at one fewer than the catalog so ≥1 model always stays enabled. Derived from the +// catalog length so it can't drift as the catalog grows (e.g. the DeepSeek free entry). +const DisabledModelsSchema = z.array(CatalogModelIdSchema).max(AGENT_MODEL_CATALOG.length - 1); const SurfaceBudgetSchema = z.number().positive().max(50); // 0 < n ≤ $50; null = No cap export const UserProfileSchema = z @@ -26,6 +27,12 @@ export const UserProfileSchema = z onboardingCompletedAt: z.string().datetime().nullable(), onboardingState: OnboardingStateSchema, updatedAt: z.string().datetime().nullable(), + // Server-computed free DeepSeek allowance, surfaced for the Models page meter. + // Optional so a new web bundle tolerates a gateway response that predates this + // field while the two workers deploy independently (deploy skew). + freeDeepseek: z + .object({ limit: z.number().int().positive(), used: z.number().int().nonnegative() }) + .optional(), }) .strict(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cfd863..0835896 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@ai-sdk/anthropic': specifier: 3.0.84 version: 3.0.84 + '@ai-sdk/deepseek': + specifier: 2.0.39 + version: 2.0.39 '@ai-sdk/google': specifier: 3.0.82 version: 3.0.82 @@ -577,6 +580,9 @@ importers: '@ai-sdk/anthropic': specifier: 'catalog:' version: 3.0.84(zod@4.4.3) + '@ai-sdk/deepseek': + specifier: 'catalog:' + version: 2.0.39(zod@4.4.3) '@ai-sdk/google': specifier: 'catalog:' version: 3.0.82(zod@4.4.3) @@ -960,6 +966,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/deepseek@2.0.39': + resolution: {integrity: sha512-3wUq1cvqSWkRadCSqcDXBLpOwUAe+JqfWQZS0fK0sWiqDeTIEzsse+r7xcN2x5XpJjefT0d7FQTa3u2lZ/yxbg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.131': resolution: {integrity: sha512-CnjOZdywQaUnCyZ0N5wVNm7Sm63+NeHDVZQJKFX2IDq+t03SLwiiuoi3ILTLPlM+YSOhkQ/pvIDoR4qa98Zp5A==} engines: {node: '>=18'} @@ -996,6 +1008,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.30': + resolution: {integrity: sha512-VO7I+vPffqI5sMnPoUq5DCSqKIgQIk/naJWRdQVpz2ma2zoprC/lqiJiUEl2s6DfvTD76TbhD3q39ROjlA6rGw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@2.0.3': resolution: {integrity: sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww==} engines: {node: '>=18'} @@ -7701,6 +7719,12 @@ snapshots: '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3) zod: 4.4.3 + '@ai-sdk/deepseek@2.0.39(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.30(zod@4.4.3) + zod: 4.4.3 + '@ai-sdk/gateway@3.0.131(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -7741,6 +7765,13 @@ snapshots: eventsource-parser: 3.1.0 zod: 4.4.3 + '@ai-sdk/provider-utils@4.0.30(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + zod: 4.4.3 + '@ai-sdk/provider@2.0.3': dependencies: json-schema: 0.4.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 85c40c4..c8bdc34 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: catalog: '@ai-sdk/anthropic': 3.0.84 + '@ai-sdk/deepseek': 2.0.39 '@ai-sdk/google': 3.0.82 '@ai-sdk/openai': 3.0.71 '@ai-sdk/react': 3.0.207 diff --git a/scripts/sync-secrets.ts b/scripts/sync-secrets.ts index 1fd0374..c6bb3a8 100644 --- a/scripts/sync-secrets.ts +++ b/scripts/sync-secrets.ts @@ -66,6 +66,7 @@ export const SECRET_SPECS: readonly SecretSpec[] = [ { envKeys: ["POLAR_ACCESS_TOKEN"], secretName: "polar-access-token" }, { envKeys: ["POLAR_WEBHOOK_SECRET"], secretName: "polar-webhook-secret" }, { envKeys: ["COMPOSIO_API_KEY"], secretName: "composio-api-key" }, + { envKeys: ["DEEPSEEK_PLATFORM_API_KEY"], secretName: "deepseek-platform-api-key" }, { envKeys: ["COMPOSIO_AUTH_CONFIGS"], secretName: "composio-auth-configs" }, { envKeys: ["COMPOSIO_WEBHOOK_SECRET"], secretName: "composio-webhook-secret" }, {