From 298f0db262eeb4f99c10a57ba1c444851b09d2c1 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 18 May 2026 01:06:05 +0900 Subject: [PATCH 1/6] feat(ai): BYOK provider layer; drop GRIDA_LOCALDEV_SUPERUSER Contributors had no way to exercise AI features locally without a Vercel AI Gateway key + Metronome billing wired up. The only escape was NEXT_PUBLIC_GRIDA_LOCALDEV_SUPERUSER, a public flag that bypassed both billing AND auth. Introduce a BYOK layer: when BYOK_OPENROUTER_API_KEY (OpenRouter via @ai-sdk/openai-compatible) or BYOK_AI_GATEWAY_API_KEY (dedicated AI Gateway key) is set, `grida`/`model` return a bare provider that structurally bypasses the billing seam (no gate, no Metronome ingest, no balance). The contributor's own key pays the provider directly. BYOK bypasses billing ONLY, never auth: requireOrganizationId and route/action auth always run. The old superuser flag is removed entirely, which makes the MissingOrgIdError/gate contract unconditional on the billed path (a net GRIDA-SEC-003 hardening). Behavior change: local AI without a BYOK key now requires real billing + always requires auth; the supported local path is "set a BYOK key". SECURITY.md documents the carve-out + residual risk; test/billing-quota-and-ai.md TC-035/036 retargeted. --- SECURITY.md | 34 +++++++++-- docs/contributing/billing.md | 23 ++++++++ editor/.env.example | 11 +++- editor/.oxlintrc.jsonc | 2 + editor/app/(api)/private/ai/chat/route.ts | 53 +++++++---------- editor/env.ts | 10 ---- editor/lib/ai/__tests__/server.test.ts | 62 +++++++++++--------- editor/lib/ai/models.ts | 66 ++++++++++++++++++++-- editor/lib/ai/server.ts | 69 +++++++++++------------ editor/package.json | 1 + editor/scripts/audit-ai-seam.ts | 1 + pnpm-lock.yaml | 42 ++++++++++++++ test/billing-quota-and-ai.md | 13 ++--- 13 files changed, 265 insertions(+), 122 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index ee9d11acf6..237361b041 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -221,11 +221,14 @@ inputOrgId })`. It resolves from: route param slug → request id is verified via `assertOrgMember(user_id, org_id)` before return. No "current org" is read from session blob / cookie. 2. **Runtime contract in the seam** — - [editor/lib/ai/\_seam/core.ts](editor/lib/ai/_seam/core.ts) + [editor/lib/ai/server.ts](editor/lib/ai/server.ts) `withTransaction` (and the AI SDK middleware that wraps it) throw `MissingOrgIdError` if `organizationId` is missing, non-integer, - or non-positive. Defense-in-depth — a caller that forgets to - verify still cannot pass a garbage value. + or non-positive. This is **unconditional** on the billed path: the + former `NEXT_PUBLIC_GRIDA_LOCALDEV_SUPERUSER` exception (synthetic + `organizationId:0`, gate/ingest/auth skip) has been **removed** — + no code path skips this check while billing. The only intentional + bypass is the BYOK carve-out below, and it does not bill. 3. **Single seam entry point** — [editor/lib/ai/server.ts](editor/lib/ai/server.ts) is the ONLY file allowed to import `replicate`, `openai`, `@ai-sdk/*`, @@ -235,12 +238,33 @@ inputOrgId })`. It resolves from: route param slug → request ([editor/scripts/audit-ai-seam.ts](editor/scripts/audit-ai-seam.ts)). A new file that bypasses the seam fails at lint or CI. +**BYOK carve-out (intentional).** When a contributor sets a `BYOK_*` +key ([editor/lib/ai/models.ts](editor/lib/ai/models.ts) — +`BYOK_OPENROUTER_API_KEY`, `BYOK_AI_GATEWAY_API_KEY`), `grida`/`model` +return a **bare** provider and the billing seam is bypassed entirely: +no gate, no Metronome ingest, no balance read, **and** the +`MissingOrgIdError` runtime contract above does not fire (a bare +provider has no middleware). The contributor's own provider key is +charged directly — there is no Grida balance, hence no victim to +drain, so the billing trust boundary is moot for that path. **BYOK +bypasses billing only — never auth.** `requireOrganizationId` and +route/action auth always run, so a logged-in user with no resolvable +org is still rejected. Gated solely by server-only, non-`NEXT_PUBLIC_` +env vars never set in the hosted product (same trust model as +`OPENAI_API_KEY` / `REPLICATE_API_TOKEN`). Fail-closed: `byok` is +`null` unless a key env var is a non-empty string, so any ambiguity +falls back to the billed path. **Residual risk:** `byok` is resolved +once at module load with no per-request guard — an accidental `BYOK_*` +on a hosted/preview deploy would make every org bypass billing and the +org-id sanity gate (auth still holds). Acceptable only because it is a +contributor/self-host switch under the existing server-env trust model. + **Files bound by this id.** Run `grep -rn GRIDA-SEC-003 .` to enumerate. Today: - [editor/lib/auth/organization.ts](editor/lib/auth/organization.ts) — `requireOrganizationId`. -- [editor/lib/ai/server.ts](editor/lib/ai/server.ts) — single seam entry. -- [editor/lib/ai/\_seam/core.ts](editor/lib/ai/_seam/core.ts) — runtime gate. +- [editor/lib/ai/server.ts](editor/lib/ai/server.ts) — single seam entry; unconditional runtime gate; BYOK layer switch. +- [editor/lib/ai/models.ts](editor/lib/ai/models.ts) — BYOK layer (bare provider, bypasses billing). - [editor/.oxlintrc.jsonc](editor/.oxlintrc.jsonc) — import lint rule. - [editor/scripts/audit-ai-seam.ts](editor/scripts/audit-ai-seam.ts) — CI audit. diff --git a/docs/contributing/billing.md b/docs/contributing/billing.md index dd105f33a3..71010c43db 100644 --- a/docs/contributing/billing.md +++ b/docs/contributing/billing.md @@ -9,6 +9,26 @@ Setup guide for contributors working on the billing surface. Two clouds to wire: --- +## Just need AI to work? BYOK instead (no billing setup) + +If you are **not** working on the billing surface and only need AI features (canvas agent, chat) to run locally, skip the entire Metronome / Stripe / tunnel setup below. Set a contributor **BYOK** key and the AI seam routes through your own provider with the billing seam **bypassed entirely** — no credit gate, no metering, no Metronome. + +```bash +# editor/.env.local (gitignored) +BYOK_OPENROUTER_API_KEY=sk-or-v1-... # https://openrouter.ai/keys +# …or, if you have one, a dedicated Vercel AI Gateway key: +# BYOK_AI_GATEWAY_API_KEY=... +``` + +- Bypasses **billing only — never auth.** Still sign in (`insider@grida.co` / `password`); a resolvable org is still required (an unauthenticated request still 401s). +- **Text/chat only** — OpenRouter exposes no image/audio models. Catalog model IDs are unchanged; use IDs your provider accepts (edit `editor/lib/ai/models.ts` locally if one 404s). +- Precedence if both are set: OpenRouter, then AI Gateway. Fail-closed — an empty/unset key falls back to the billed path. +- **Never set `BYOK_*` on a hosted or preview deploy.** It disables billing **and** the org-id sanity gate for every org. Contributor / self-host / local only. See [SECURITY.md](../../SECURITY.md) `GRIDA-SEC-003` (BYOK carve-out). + +Working on billing itself? Ignore BYOK and continue with the full setup below. + +--- + ## What you need - Local Supabase running (`supabase start`). @@ -162,6 +182,7 @@ User-facing billing copy: [`docs/platform/billing.mdx`](../platform/billing.mdx) - **Tunnel returns 404** — `WEBHOOK_TUNNEL_HOSTNAME` doesn't match the routed hostname, or `cloudflared` isn't running. Re-run from `cli.ts smoke:webhook` to pinpoint which layer is broken. - **Customer Portal "no Stripe customer"** — org hasn't subscribed or topped up yet. Stripe customer is lazy-created on first paid action. - **AI credit shows "Out of credit" forever after a successful top-up** — Metronome webhook didn't reach the tunnel. Run `cli.ts smoke:webhook` to verify each layer. +- **AI returns a 402 / credit-gate error and you're _not_ testing billing** — you don't have Metronome wired. Set `BYOK_OPENROUTER_API_KEY` (see [Just need AI to work?](#just-need-ai-to-work-byok-instead-no-billing-setup)) — it bypasses the gate entirely. If it's set and you _still_ see billing behavior, the key is empty or AI is being called before sign-in (BYOK never bypasses auth). --- @@ -177,3 +198,5 @@ User-facing billing copy: [`docs/platform/billing.mdx`](../platform/billing.mdx) | `METRONOME_WEBHOOK_SECRET` | `editor/.env.test.local` | | `WEBHOOK_TUNNEL_HOSTNAME` | `editor/.env.test.local` | | `BILLING_E2E`, `BILLING_TEST_MODE`, `APP_URL` | `editor/.env.test` (committed) | + +**Contributor BYOK (alternative — not required):** `BYOK_OPENROUTER_API_KEY` or `BYOK_AI_GATEWAY_API_KEY` in `editor/.env.local`. When set, the AI seam bypasses billing entirely and **none** of the Metronome rows above are needed. Auth is still required. See [Just need AI to work?](#just-need-ai-to-work-byok-instead-no-billing-setup). diff --git a/editor/.env.example b/editor/.env.example index 9f7726bec7..65bdc98852 100644 --- a/editor/.env.example +++ b/editor/.env.example @@ -29,7 +29,6 @@ NEXT_PUBLIC_GRIDA_UNSAFE_DEVELOPER_SANDBOX='0' NEXT_PUBLIC_GRIDA_WASM_VERBOSE='0' # set 1 to inspect (log) the wasm api NEXT_PUBLIC_GRIDA_USE_INSIDERS_AUTH='1' NEXT_PUBLIC_GRIDA_LOCALHOST_REGION="us-west-1" -NEXT_PUBLIC_GRIDA_LOCALDEV_SUPERUSER='0' # set 1 to byok and test features without rate limits. purely local dev with production build purpose. # [optional] # some features may require these keys @@ -40,8 +39,16 @@ NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=... # openai (used by provider-specific tools like webSearch; model selection is in lib/ai/models.ts) OPENAI_API_KEY='sk-xxx' -# vercel ai gateway (optional; used when deployed on Vercel or with an API key) +# vercel ai gateway (optional; billed path — implicit AI_GATEWAY_API_KEY/OIDC) # AI_GATEWAY_API_KEY=... +# +# byok (contributor-only; local testing). when ANY is set, AI calls +# route through that provider and BYPASS the billing layer (no credit +# gate, no metering). does NOT bypass auth (login + org still required). +# text/chat only; catalog model IDs unchanged (use IDs the provider +# accepts). precedence: openrouter, then ai-gateway. +# BYOK_OPENROUTER_API_KEY=sk-or-v1-xxx # https://openrouter.ai/keys +# BYOK_AI_GATEWAY_API_KEY=... # dedicated Vercel AI Gateway key # resend RESEND_API_KEY='re_123' diff --git a/editor/.oxlintrc.jsonc b/editor/.oxlintrc.jsonc index ca79f45bf4..4cc382559e 100644 --- a/editor/.oxlintrc.jsonc +++ b/editor/.oxlintrc.jsonc @@ -46,6 +46,8 @@ "group": [ "@ai-sdk/openai", "@ai-sdk/openai/**", + "@ai-sdk/openai-compatible", + "@ai-sdk/openai-compatible/**", "@ai-sdk/anthropic", "@ai-sdk/anthropic/**", "@ai-sdk/google", diff --git a/editor/app/(api)/private/ai/chat/route.ts b/editor/app/(api)/private/ai/chat/route.ts index 918a45a900..f6ecb75441 100644 --- a/editor/app/(api)/private/ai/chat/route.ts +++ b/editor/app/(api)/private/ai/chat/route.ts @@ -5,7 +5,6 @@ import { type UIMessage, } from "ai"; import { canvasDesignAgent } from "@/grida-canvas-hosted/ai/agent/server-agent"; -import { Env } from "@/env"; import { createClient } from "@/lib/supabase/server"; import { modelSpecById } from "@/lib/ai/models"; import { requireOrganizationId } from "@/lib/auth/organization"; @@ -26,25 +25,24 @@ type AgentChatRequestBody = { export async function POST(req: NextRequest) { try { // GRIDA-SEC-003: resolve the calling org with verified membership. - let organizationId: number | null = null; - if (!Env.web.IS_LOCALDEV_SUPERUSER) { - const client = await createClient(); - const { data: userdata, error: authError } = await client.auth.getUser(); - if (authError || !userdata.user) { - return aiErrorResponse({ - code: "unauthorized", - status: 401, - message: "login required", - }); - } - try { - organizationId = await requireOrganizationId({ - user_id: userdata.user.id, - request: req, - }); - } catch (err) { - return aiErrorResponse(orgErrorToAiError(err)); - } + // Auth is always enforced — BYOK bypasses billing only, never auth. + const client = await createClient(); + const { data: userdata, error: authError } = await client.auth.getUser(); + if (authError || !userdata.user) { + return aiErrorResponse({ + code: "unauthorized", + status: 401, + message: "login required", + }); + } + let organizationId: number; + try { + organizationId = await requireOrganizationId({ + user_id: userdata.user.id, + request: req, + }); + } catch (err) { + return aiErrorResponse(orgErrorToAiError(err)); } const { messages } = (await req.json()) as AgentChatRequestBody; @@ -54,23 +52,12 @@ export async function POST(req: NextRequest) { let lastModelId: string | undefined; let lastStepUsage: LanguageModelUsage | undefined; - // organizationId is required unless we're in the local-dev superuser - // mode (no auth/billing). The agent's `prepareCall` injects this into + // The agent's `prepareCall` injects organizationId into // providerOptions.grida — see GRIDA-SEC-003. - if (organizationId === null && !Env.web.IS_LOCALDEV_SUPERUSER) { - return aiErrorResponse({ - code: "no_organization", - status: 412, - message: "organizationId required", - }); - } return createAgentUIStreamResponse({ agent: canvasDesignAgent, uiMessages: messages, - options: - organizationId !== null - ? { organizationId, feature: "canvas/agent/chat" } - : ({} as { organizationId: number; feature?: string }), + options: { organizationId, feature: "canvas/agent/chat" }, sendReasoning: true, messageMetadata: ({ part }): AgentMessageMetadata | undefined => { if (part.type === "finish-step") { diff --git a/editor/env.ts b/editor/env.ts index b590ff9f9a..3685663676 100644 --- a/editor/env.ts +++ b/editor/env.ts @@ -66,16 +66,6 @@ export namespace Env { ? // VERCEL_URL does not have protocol "https://" + process.env.NEXT_PUBLIC_URL : "http://localhost:3000"; - - /** - * flag for local testing with superuser access - * turning this on is safe, it wont affect the hosted environment. - * - * what this does: - * it overrides the rate limit & "payment required" parts, yet you'll have to set your own keys. - */ - export const IS_LOCALDEV_SUPERUSER = - process.env.NEXT_PUBLIC_GRIDA_LOCALDEV_SUPERUSER === "1"; } export namespace vercel { diff --git a/editor/lib/ai/__tests__/server.test.ts b/editor/lib/ai/__tests__/server.test.ts index 3f40246525..44f2fed7d7 100644 --- a/editor/lib/ai/__tests__/server.test.ts +++ b/editor/lib/ai/__tests__/server.test.ts @@ -27,6 +27,15 @@ vi.mock("@/lib/auth/organization", () => ({ requireOrganizationId: vi.fn<(...args: never[]) => unknown>(), })); +// Partial passthrough: keep real `catalog`/`gateway`/`modelSpecById`/ +// `tiers`/`byok` (the cost + gate suites depend on them); only stub +// `isByokActive` so a test can flip the BYOK carve-out without setting +// a module-load env var. +vi.mock("../models", async (orig) => ({ + ...(await orig()), + isByokActive: vi.fn<() => boolean>(), +})); + import { getEntitlement, ingestUsageEvent, @@ -35,6 +44,7 @@ import { } from "@/lib/billing/metronome"; import { createLibraryClient } from "@/lib/supabase/server"; import { requireOrganizationId } from "@/lib/auth/organization"; +import { isByokActive } from "../models"; import { withTransaction, withAiAuth, @@ -48,14 +58,19 @@ const mockedIngestUsageEvent = vi.mocked(ingestUsageEvent); const mockedRefreshBalance = vi.mocked(refreshBalance); const mockedCreateLibraryClient = vi.mocked(createLibraryClient); const mockedRequireOrganizationId = vi.mocked(requireOrganizationId); +const mockedIsByokActive = vi.mocked(isByokActive); beforeEach(() => { - delete process.env.NEXT_PUBLIC_GRIDA_LOCALDEV_SUPERUSER; + delete process.env.BYOK_OPENROUTER_API_KEY; + delete process.env.BYOK_AI_GATEWAY_API_KEY; mockedGetEntitlement.mockReset(); mockedIngestUsageEvent.mockReset(); mockedRefreshBalance.mockReset(); mockedCreateLibraryClient.mockReset(); mockedRequireOrganizationId.mockReset(); + mockedIsByokActive.mockReset(); + // Default: BYOK inactive (billed path) unless a test opts in. + mockedIsByokActive.mockReturnValue(false); }); function stubAuthed(orgId = 7) { @@ -241,31 +256,6 @@ describe("withTransaction", () => { expect.anything() ); }); - - it("skips gate + ingest in superuser dev mode", async () => { - process.env.NEXT_PUBLIC_GRIDA_LOCALDEV_SUPERUSER = "1"; - const op = vi.fn< - ( - tx: string - ) => Promise<{ result: { tx: string; value: string }; costMills: number }> - >(async (tx: string) => ({ - result: { tx, value: "ok" }, - costMills: 99, - })); - - const out = await withTransaction( - { - organizationId: 0, - feature: "dev", - model_id: "openai/gpt-5.4-mini", - }, - op - ); - - expect(out).toEqual(expect.objectContaining({ value: "ok" })); - expect(mockedGetEntitlement).not.toHaveBeenCalled(); - expect(mockedIngestUsageEvent).not.toHaveBeenCalled(); - }); }); describe("withAiAuth envelope", () => { @@ -298,6 +288,26 @@ describe("withAiAuth envelope", () => { expect(result).toEqual({ success: true, data: { reply: "silent" } }); expect(mockedRefreshBalance).not.toHaveBeenCalled(); }); + + it("under BYOK: skips refreshBalance, returns balanceCents:0, auth still enforced", async () => { + mockedIsByokActive.mockReturnValue(true); + stubAuthed(7); + + const result = await withAiAuth("test/scope", undefined, async (orgId) => ({ + reply: "byok", + org: orgId, + })); + + expect(result).toEqual({ + success: true, + data: { reply: "byok", org: 7, balanceCents: 0 }, + }); + // BYOK bypasses billing only — auth + org resolution still ran. + expect(mockedCreateLibraryClient).toHaveBeenCalled(); + expect(mockedRequireOrganizationId).toHaveBeenCalled(); + // The Metronome balance read is skipped. + expect(mockedRefreshBalance).not.toHaveBeenCalled(); + }); }); describe("checkGate", () => { diff --git a/editor/lib/ai/models.ts b/editor/lib/ai/models.ts index 4859365226..7193e38e3a 100644 --- a/editor/lib/ai/models.ts +++ b/editor/lib/ai/models.ts @@ -29,6 +29,7 @@ */ import { createGateway } from "ai"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; // --------------------------------------------------------------------------- // Tier definitions @@ -227,6 +228,17 @@ export function modelSpecById(modelId: string): ModelSpec | undefined { // @see https://vercel.com/docs/ai-gateway/ecosystem/app-attribution // --------------------------------------------------------------------------- +/** + * Vercel AI Gateway app-attribution headers — lowercase per the `ai` + * SDK convention. Shared by the billed `gateway` and the BYOK + * AI-Gateway branch. (OpenRouter uses its own `HTTP-Referer`/`X-Title` + * casing — see `resolveByokProvider`.) + */ +const GATEWAY_ATTRIBUTION_HEADERS = { + "http-referer": "https://grida.co", + "x-title": "Grida", +} as const; + /** * Attributed AI Gateway instance. * @@ -240,12 +252,58 @@ export function modelSpecById(modelId: string): ModelSpec | undefined { * [editor/.oxlintrc.jsonc](../../.oxlintrc.jsonc)). */ export const gateway = createGateway({ - headers: { - "http-referer": "https://grida.co", - "x-title": "Grida", - }, + headers: GATEWAY_ATTRIBUTION_HEADERS, }); +// --------------------------------------------------------------------------- +// BYOK layer — GRIDA-SEC-003 carve-out (see /SECURITY.md). +// +// **Internal — seam consumers only.** `byok` holds a live provider API +// key; like `gateway`, consume only from `lib/ai/server.ts`. (No lint +// rule enforces this for the named export — `.oxlintrc.jsonc` restricts +// SDK *packages*, not `gateway`/`byok` imports — convention only, +// mirroring `gateway`.) +// +// When a contributor sets a BYOK key, calls route through a BARE +// provider that bypasses the billing seam entirely (no gate, no +// Metronome ingest, no balance). The key is charged by the upstream +// provider directly — no Grida balance to meter/drain. BYOK bypasses +// billing ONLY, never auth (requireOrganizationId still runs). Gated +// solely by server-only env vars NEVER set in the hosted product (same +// trust model as OPENAI_API_KEY / REPLICATE_API_TOKEN). Fail-closed: +// active only when a key env var is a non-empty string. +// +// Implementations (precedence: OpenRouter first, then AI Gateway). A +// third BYOK key is a new branch here — no registry. +// --------------------------------------------------------------------------- +function resolveByokProvider() { + const openrouterKey = process.env.BYOK_OPENROUTER_API_KEY; + if (openrouterKey) { + return createOpenAICompatible({ + name: "openrouter", + baseURL: "https://openrouter.ai/api/v1", + apiKey: openrouterKey, + headers: { "HTTP-Referer": "https://grida.co", "X-Title": "Grida" }, + }); + } + const aiGatewayKey = process.env.BYOK_AI_GATEWAY_API_KEY; + if (aiGatewayKey) { + return createGateway({ + apiKey: aiGatewayKey, + headers: GATEWAY_ATTRIBUTION_HEADERS, + }); + } + return null; +} + +/** The active BYOK provider, or `null` when no BYOK key is set. */ +export const byok = resolveByokProvider(); + +/** True when the BYOK layer is active (billing layer is bypassed). */ +export function isByokActive(): boolean { + return byok !== null; +} + // `model(tier)` lives in `editor/lib/ai/server.ts` so the seam owns the // public surface. Importing `model` from `@/lib/ai/models` is no longer // supported. diff --git a/editor/lib/ai/server.ts b/editor/lib/ai/server.ts index d4e1b6ab54..a0ad299509 100644 --- a/editor/lib/ai/server.ts +++ b/editor/lib/ai/server.ts @@ -21,6 +21,12 @@ import "server-only"; * * `organizationId` MUST come from `requireOrganizationId` — * see [editor/lib/auth/organization.ts](../auth/organization.ts). + * + * **BYOK carve-out (contributor-only).** When a `BYOK_*` key is set + * (see `editor/lib/ai/models.ts`), `grida`/`model` return a BARE + * provider and the billing seam is bypassed entirely — no gate, no + * Metronome ingest, no balance. BYOK bypasses billing ONLY: auth and + * `requireOrganizationId` always run. GRIDA-SEC-003 — see SECURITY.md. */ import type { @@ -41,8 +47,10 @@ import { BillingMetronomeError, } from "@/lib/billing/metronome"; import { + byok, catalog, gateway, + isByokActive, modelSpecById, tiers, type ModelSpec, @@ -136,10 +144,6 @@ export type { ModelTier }; // Core seam — gate → run → ingest // =========================================================================== -function isSuperuserDev(): boolean { - return process.env.NEXT_PUBLIC_GRIDA_LOCALDEV_SUPERUSER === "1"; -} - function assertOrgId(orgId: unknown): asserts orgId is number { if ( typeof orgId !== "number" || @@ -167,11 +171,11 @@ function logIngestFailure( /** * Gate-check only. Used by the streaming path where ingest is deferred - * to the `finish` part — denial must fire up front. Superuser bypass: - * no-op. + * to the `finish` part — denial must fire up front. Unconditional on + * the billed path; BYOK callers never reach here (bare provider, no + * middleware). GRIDA-SEC-003. */ export async function checkGate(ctx: GridaCallContext): Promise { - if (isSuperuserDev()) return; assertOrgId(ctx.organizationId); const e = await getEntitlement(ctx.organizationId); if (!e.allowed) { @@ -197,8 +201,6 @@ export async function withTransaction( const transactionId = ctx.transactionId ?? crypto.randomUUID(); const { result, costMills } = await op(transactionId); - if (isSuperuserDev()) return result; - // Default to `awaitIngest:true` — the common path is `withAiAuth` // which reads back the balance after `fn` resolves. Streaming // callers (canvas agent route) don't go through `withTransaction` @@ -339,9 +341,6 @@ function extractContext( typeof g.awaitIngest === "boolean" ? g.awaitIngest : undefined, }; } - if (isSuperuserDev()) { - return { organizationId: 0, feature: "dev-superuser", model_id: modelId }; - } throw new MissingOrgIdError( `AI SDK call missing providerOptions.grida.organizationId (model=${modelId}). ` + `Pass { providerOptions: { grida: { organizationId, feature } } } to the SDK call.` @@ -382,7 +381,7 @@ const languageModelMiddleware: LanguageModelMiddleware = { controller.enqueue(part); }, flush() { - if (finalCostMills !== null && !isSuperuserDev()) { + if (finalCostMills !== null) { void ingestUsageEvent(ctx.organizationId, finalCostMills, { transactionId, }).catch(logIngestFailure(ctx, transactionId)); @@ -428,20 +427,26 @@ export type GridaProvider = ((modelId: string) => LanguageModel) & { }; /** - * Billing-wrapped Vercel AI Gateway provider. Same routing as the bare - * `gateway` but every call is funneled through the billing middleware. + * The seam's public model provider. Default: the billing-wrapped Vercel + * AI Gateway (gate + ingest middleware). When a `BYOK_*` key is set this + * is a bare provider that bypasses billing — see the BYOK carve-out in + * this file's header / `models.ts` / SECURITY.md GRIDA-SEC-003. * * grida("openai/gpt-5.4-mini") // → LanguageModel (callable shorthand) * grida.languageModel("openai/...") // → LanguageModel (explicit) * grida.imageModel("bfl/flux-2-pro") // → ImageModel */ +// Resolve the active provider once: the BYOK bare provider when a +// contributor key is set, else the billing-wrapped gateway. +const activeProvider = byok ?? wrappedProvider; + function gridaFn(modelId: string): LanguageModel { - return wrappedProvider.languageModel(modelId); + return activeProvider.languageModel(modelId); } gridaFn.languageModel = (modelId: string): LanguageModel => - wrappedProvider.languageModel(modelId); + activeProvider.languageModel(modelId); gridaFn.imageModel = (modelId: string): ImageModel => - wrappedProvider.imageModel(modelId); + activeProvider.imageModel(modelId); export const grida: GridaProvider = gridaFn; @@ -729,10 +734,10 @@ export type WithAiAuthOptions = { * Metronome read post-fn). The `withTransaction` middleware defaults to * `awaitIngest:true` so the post-fn read sees the reconciled value. * - * In `IS_LOCALDEV_SUPERUSER` mode the auth + org lookup is skipped and - * `fn(0)` runs; the seam middleware (`extractContext`, `checkGate`, - * `withTransaction`) recognises `orgId === 0` via the same flag and - * skips gate + ingest. Balance is reported as `0`. + * BYOK (contributor-only): auth + org lookup still run — BYOK never + * bypasses auth. `fn` executes against a bare provider so the billing + * middleware is skipped entirely; the post-fn Metronome balance read is + * also skipped and balance is reported as `0`. GRIDA-SEC-003. */ export function withAiAuth>( scope: string, @@ -752,19 +757,6 @@ export async function withAiAuth>( fn: (orgId: number) => Promise, opts: WithAiAuthOptions = {} ): Promise | AiActionResult> { - if (isSuperuserDev()) { - let data: T; - try { - data = await fn(0); - } catch (err) { - return billingErrorToAiError(err, scope); - } - if (opts.balance === false) return { success: true, data }; - return { - success: true, - data: { ...data, balanceCents: 0 } as AiActionData, - }; - } const client = await createLibraryClient(); const { data: userdata } = await client.auth.getUser(); if (!userdata.user) { @@ -793,6 +785,13 @@ export async function withAiAuth>( if (opts.balance === false) { return { success: true, data }; } + if (isByokActive()) { + // GRIDA-SEC-003 BYOK carve-out: no Grida balance to read. + return { + success: true, + data: { ...data, balanceCents: 0 } as AiActionData, + }; + } // The action already succeeded — a Metronome read failure must not // demote the envelope to `success: false`. Surface the data and a // sentinel balance (-1); clients can `useAiCredits().refresh()` to diff --git a/editor/package.json b/editor/package.json index 312f3f9d9c..f833cd33aa 100644 --- a/editor/package.json +++ b/editor/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@ai-sdk/openai": "3.0.0", + "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/react": "3.0.1", "@ai-sdk/replicate": "2.0.0", "@ai-sdk/rsc": "2.0.1", diff --git a/editor/scripts/audit-ai-seam.ts b/editor/scripts/audit-ai-seam.ts index d0673a8f59..d73d7dc024 100644 --- a/editor/scripts/audit-ai-seam.ts +++ b/editor/scripts/audit-ai-seam.ts @@ -50,6 +50,7 @@ const FORBIDDEN_PACKAGES = [ "openai", "@anthropic-ai/sdk", "@ai-sdk/openai", + "@ai-sdk/openai-compatible", "@ai-sdk/anthropic", "@ai-sdk/google", "@ai-sdk/google-vertex", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9124d2786..a9e20e2504 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,6 +304,9 @@ importers: '@ai-sdk/openai': specifier: 3.0.0 version: 3.0.0(zod@4.3.6) + '@ai-sdk/openai-compatible': + specifier: 2.0.47 + version: 2.0.47(zod@4.3.6) '@ai-sdk/react': specifier: 3.0.1 version: 3.0.1(react@19.2.5)(zod@4.3.6) @@ -1393,6 +1396,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai-compatible@2.0.47': + resolution: {integrity: sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai@3.0.0': resolution: {integrity: sha512-/o2xCQlRA+O0cAXIIBOfMeT35H6Fonzilz9r/IJojPOMQnmIL+0jPQVKOUPr5bouRqCjnwKpwuKEBRqm8jUZkQ==} engines: {node: '>=18'} @@ -1411,10 +1420,20 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.27': + resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@3.0.0': resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.10': + resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} + engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} @@ -8876,6 +8895,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} @@ -14428,6 +14451,12 @@ snapshots: '@vercel/oidc': 3.1.0 zod: 4.3.6 + '@ai-sdk/openai-compatible@2.0.47(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) + zod: 4.3.6 + '@ai-sdk/openai@3.0.0(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.0 @@ -14448,10 +14477,21 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.27(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.8 + zod: 4.3.6 + '@ai-sdk/provider@3.0.0': dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@3.0.10': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 @@ -23329,6 +23369,8 @@ snapshots: eventsource-parser@3.0.6: {} + eventsource-parser@3.0.8: {} + eventsource@3.0.7: dependencies: eventsource-parser: 3.0.6 diff --git a/test/billing-quota-and-ai.md b/test/billing-quota-and-ai.md index bde6adac8f..8f093799a0 100644 --- a/test/billing-quota-and-ai.md +++ b/test/billing-quota-and-ai.md @@ -199,16 +199,15 @@ Some token models return cached prompts at deeply discounted rates; total comes Bug in the cost calculator returns a negative number. **Expected:** Refused at the storage layer. The faulty record is not persisted; ops is notified. -### TC-BILLING-AI-035 — Local-dev superuser bypass +### TC-BILLING-AI-035 — BYOK billing bypass (contributor key set) -Developer flag is set. AI call goes through. -**Expected:** Pre-flight is skipped. Usage is still logged for audit, but it does not count against allowance. -**Niche:** Two flags must both be set (non-production AND superuser env). Test the AND. +A contributor sets a `BYOK_*` key (e.g. `BYOK_OPENROUTER_API_KEY`). AI call goes through. +**Expected:** Calls route through the bare BYOK provider. The credit gate and Metronome metering are skipped entirely (no allowance impact) — the contributor's own key pays the provider. Auth is still enforced: an unauthenticated request, or a user with no resolvable org, is still rejected (BYOK bypasses billing only, never auth). -### TC-BILLING-AI-036 — Superuser flag accidentally set in production +### TC-BILLING-AI-036 — BYOK key accidentally set on a hosted deploy -Bug: developer env var leaks to prod. -**Expected:** Bypass is disabled because the production check fails. The gate enforces normally. +Bug: a `BYOK_*` secret leaks into a hosted/preview deploy. +**Expected:** There is **no code-level production guard** (by design — same server-env trust model as `OPENAI_API_KEY` / `REPLICATE_API_TOKEN`). With the key set, every org's calls bypass billing and the org-id sanity gate (auth still holds). This is a documented residual risk (see `SECURITY.md` GRIDA-SEC-003), mitigated operationally by never setting the secret in the hosted product — not by code. There is no "production check" to fail. ### TC-BILLING-AI-037 — Disabled model attempted From 07a3354704e453de3aa870c22317f5a61e777889 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 18 May 2026 18:19:34 +0900 Subject: [PATCH 2/6] feat(ai): make AI credits UI BYOK-aware Under BYOK the seam forces balanceCents:0 and isByokActive() is server-only, so the credit chip/banner showed a misleading "$0 / out of credit / top up" while calls actually succeed via the contributor's own key. Plumb a `byok` flag from preloadAiCredits through the controller; useAiCredits() now returns a discriminated union where the `byok` variant omits cents/allowed/formatted, so every consumer is compile-forced to handle BYOK and render its own label. The low-balance / below-floor / top-up affordances are suppressed under BYOK across the AI chat, music, and image surfaces. Also correct a stale "non-superuser mode" comment in canvas generate.ts (GRIDA-SEC-003 hygiene from the superuser removal). GRIDA-SEC-003: isByokActive is re-exported through the seam (@/lib/ai/server); only the boolean reaches the client, never the key. --- .../app/(canvas)/canvas/tools/ai/generate.ts | 4 +- .../(playground)/playground/image/_page.tsx | 25 ++-- editor/app/(www)/(ai)/ai/_page.tsx | 136 +++++++++++------- .../(www)/(ai)/ai/music/playground/_page.tsx | 4 +- .../ai/credits/__tests__/controller.test.ts | 93 +++++++++--- editor/lib/ai/credits/actions.ts | 17 ++- editor/lib/ai/credits/controller.ts | 19 ++- editor/lib/ai/credits/index.ts | 2 +- editor/lib/ai/credits/provider.tsx | 40 +++++- editor/lib/ai/server.ts | 4 + 10 files changed, 251 insertions(+), 93 deletions(-) diff --git a/editor/app/(canvas)/canvas/tools/ai/generate.ts b/editor/app/(canvas)/canvas/tools/ai/generate.ts index 111969cae5..ba6c1ff793 100644 --- a/editor/app/(canvas)/canvas/tools/ai/generate.ts +++ b/editor/app/(canvas)/canvas/tools/ai/generate.ts @@ -38,8 +38,8 @@ export async function generate({ * shell / route param). See GRIDA-SEC-003. * * Optional only to make the dev-tool harness compile without a - * workspace; when omitted in non-superuser mode the seam middleware - * throws `MissingOrgIdError` at the first AI call. + * workspace; when omitted the billed-path seam middleware throws + * `MissingOrgIdError` at the first AI call. See GRIDA-SEC-003. */ organizationId?: number; system?: string; diff --git a/editor/app/(tools)/(playground)/playground/image/_page.tsx b/editor/app/(tools)/(playground)/playground/image/_page.tsx index 70b862cf8d..d8539c4ce1 100644 --- a/editor/app/(tools)/(playground)/playground/image/_page.tsx +++ b/editor/app/(tools)/(playground)/playground/image/_page.tsx @@ -216,18 +216,23 @@ function BudgetBadge({ credits: ReturnType; className?: string; }) { + const shell = (content: React.ReactNode) => ( +
+ {content} +
+ ); + // BYOK: no balance to show — own key pays. + if (credits.mode === "byok") { + return shell("BYOK"); + } return ( - -
- {credits.formatted ?? "—"} -
-
+ {shell(credits.formatted ?? "—")}
{credits.formattedExact ?? "—"} balance diff --git a/editor/app/(www)/(ai)/ai/_page.tsx b/editor/app/(www)/(ai)/ai/_page.tsx index 6df715ec38..e4b60f7937 100644 --- a/editor/app/(www)/(ai)/ai/_page.tsx +++ b/editor/app/(www)/(ai)/ai/_page.tsx @@ -156,6 +156,81 @@ function useDebugFlag(): [boolean, () => void] { return [debug, toggle]; } +function CreditChip({ + credits, + billingHref, + onRefresh, + refreshing, +}: { + credits: ReturnType; + billingHref: string | null; + onRefresh: () => void; + refreshing: boolean; +}) { + if (credits.mode === "byok") { + return ( + + BYOK + + ); + } + if (credits.cents === null || !billingHref) { + return ( + + no balance + + ); + } + const lowBalance = credits.cents < AI_GATE_FLOOR_CENTS; + return ( +
+ + + + + {credits.formatted ?? "—"} + + + +
+ {credits.formattedExact ?? "—"} +
+
+ live ·{" "} + {lowBalance + ? `below floor (${fmtUsd(AI_GATE_FLOOR_CENTS)}) — click to top up` + : "click to manage billing"} +
+
+
+
+ +
+ ); +} + export default function Page({ authed, context }: Props) { const credits = useAiCredits(); const [history, setHistory] = useState([]); @@ -174,9 +249,6 @@ export default function Page({ authed, context }: Props) { const billingHref = context ? `/organizations/${context.organizationSlug}/settings/billing` : null; - const lowBalance = - credits.cents !== null && credits.cents < AI_GATE_FLOOR_CENTS; - const onRefresh = useCallback(() => { if (!organizationId) return; startRefresh(async () => { @@ -252,57 +324,12 @@ export default function Page({ authed, context }: Props) {
- {credits.cents !== null && billingHref ? ( -
- - - - - {credits.formatted ?? "—"} - - - -
- {credits.formattedExact ?? "—"} -
-
- live ·{" "} - {lowBalance - ? `below floor (${fmtUsd(AI_GATE_FLOOR_CENTS)}) — click to top up` - : "click to manage billing"} -
-
-
-
- -
- ) : ( - - no balance - - )} +
@@ -448,6 +475,7 @@ export default function Page({ authed, context }: Props) { {context && + credits.mode === "billed" && !credits.allowed && credits.cents !== null && billingHref && ( diff --git a/editor/app/(www)/(ai)/ai/music/playground/_page.tsx b/editor/app/(www)/(ai)/ai/music/playground/_page.tsx index 4616b7d66c..85c04c62e4 100644 --- a/editor/app/(www)/(ai)/ai/music/playground/_page.tsx +++ b/editor/app/(www)/(ai)/ai/music/playground/_page.tsx @@ -181,7 +181,9 @@ function Workspace() {
- {credits.formatted ?? "—"} left + {credits.mode === "byok" + ? "BYOK" + : `${credits.formatted ?? "—"} left`} { describe("AiCreditsController.consume", () => { it("folds balanceCents on success and returns unwrapped data", () => { const ctrl = new AiCreditsController( - { cents: 1000, allowed: true }, + { cents: 1000, allowed: true, byok: false }, { fetcher: vi.fn<() => Promise>>(), router: fakeRouter, @@ -47,12 +47,16 @@ describe("AiCreditsController.consume", () => { }; const out = ctrl.consume(env); expect(out).toEqual({ reply: "hi", balanceCents: 990 }); - expect(ctrl.getSnapshot()).toEqual({ cents: 990, allowed: true }); + expect(ctrl.getSnapshot()).toEqual({ + cents: 990, + allowed: true, + byok: false, + }); }); it("notifies subscribers on success", () => { const ctrl = new AiCreditsController( - { cents: 1000, allowed: true }, + { cents: 1000, allowed: true, byok: false }, { fetcher: vi.fn<() => Promise>>(), router: fakeRouter, @@ -71,7 +75,7 @@ describe("AiCreditsController.consume", () => { it("returns undefined and navigates on redirect failure", () => { const navigate = vi.fn<(href: string) => void>(); const ctrl = new AiCreditsController( - { cents: 1000, allowed: true }, + { cents: 1000, allowed: true, byok: false }, { fetcher: vi.fn<() => Promise>>(), router: fakeRouter, @@ -88,13 +92,17 @@ describe("AiCreditsController.consume", () => { expect(out).toBeUndefined(); expect(fakeRouter).toHaveBeenCalledWith(env, { next: "/ai" }); expect(navigate).toHaveBeenCalledWith("/sign-in"); - expect(ctrl.getSnapshot()).toEqual({ cents: 1000, allowed: true }); + expect(ctrl.getSnapshot()).toEqual({ + cents: 1000, + allowed: true, + byok: false, + }); }); it("returns undefined and does NOT navigate on toast failure", () => { const navigate = vi.fn<(href: string) => void>(); const ctrl = new AiCreditsController( - { cents: 1000, allowed: true }, + { cents: 1000, allowed: true, byok: false }, { fetcher: vi.fn<() => Promise>>(), router: fakeRouter, @@ -110,7 +118,11 @@ describe("AiCreditsController.consume", () => { const out = ctrl.consume(env); expect(out).toBeUndefined(); expect(navigate).not.toHaveBeenCalled(); - expect(ctrl.getSnapshot()).toEqual({ cents: 1000, allowed: true }); + expect(ctrl.getSnapshot()).toEqual({ + cents: 1000, + allowed: true, + byok: false, + }); }); }); @@ -121,7 +133,7 @@ describe("AiCreditsController.refresh", () => { data: { balanceCents: 5000 }, })); const ctrl = new AiCreditsController( - { cents: 1000, allowed: false }, + { cents: 1000, allowed: false, byok: false }, { fetcher, router: fakeRouter, @@ -130,7 +142,11 @@ describe("AiCreditsController.refresh", () => { ); await ctrl.refresh(); expect(fetcher).toHaveBeenCalledOnce(); - expect(ctrl.getSnapshot()).toEqual({ cents: 5000, allowed: true }); + expect(ctrl.getSnapshot()).toEqual({ + cents: 5000, + allowed: true, + byok: false, + }); }); it("ignores result after dispose()", async () => { @@ -142,7 +158,7 @@ describe("AiCreditsController.refresh", () => { }) ); const ctrl = new AiCreditsController( - { cents: 1000, allowed: true }, + { cents: 1000, allowed: true, byok: false }, { fetcher, router: fakeRouter, @@ -153,14 +169,59 @@ describe("AiCreditsController.refresh", () => { ctrl.dispose(); resolveFetch({ success: true, data: { balanceCents: 0 } }); await p; - expect(ctrl.getSnapshot()).toEqual({ cents: 1000, allowed: true }); + expect(ctrl.getSnapshot()).toEqual({ + cents: 1000, + allowed: true, + byok: false, + }); + }); +}); + +describe("AiCreditsController BYOK persistence", () => { + // Regression: refresh()/consume() rebuild state and must not drop byok. + it("preserves byok across consume()", () => { + const ctrl = new AiCreditsController( + { cents: null, allowed: false, byok: true }, + { + fetcher: vi.fn<() => Promise>>(), + router: fakeRouter, + navigate: vi.fn<(href: string) => void>(), + } + ); + ctrl.consume({ success: true, data: { reply: "ok", balanceCents: 0 } }); + expect(ctrl.getSnapshot()).toEqual({ + cents: 0, + allowed: true, + byok: true, + }); + }); + + it("preserves byok across refresh()", async () => { + const fetcher = vi.fn<() => Promise>>(async () => ({ + success: true as const, + data: { balanceCents: 0 }, + })); + const ctrl = new AiCreditsController( + { cents: null, allowed: false, byok: true }, + { + fetcher, + router: fakeRouter, + navigate: vi.fn<(href: string) => void>(), + } + ); + await ctrl.refresh(); + expect(ctrl.getSnapshot()).toEqual({ + cents: 0, + allowed: true, + byok: true, + }); }); }); describe("AiCreditsController subscribe / getSnapshot", () => { it("subscribe returns an unsubscribe fn that removes the listener", () => { const ctrl = new AiCreditsController( - { cents: 1000, allowed: true }, + { cents: 1000, allowed: true, byok: false }, { fetcher: vi.fn<() => Promise>>(), router: fakeRouter, @@ -178,7 +239,7 @@ describe("AiCreditsController subscribe / getSnapshot", () => { it("getSnapshot returns the current state by reference identity for unchanged calls", () => { const ctrl = new AiCreditsController( - { cents: 1000, allowed: true }, + { cents: 1000, allowed: true, byok: false }, { fetcher: vi.fn<() => Promise>>(), router: fakeRouter, @@ -194,7 +255,7 @@ describe("AiCreditsController subscribe / getSnapshot", () => { describe("AiCreditsController.dispose", () => { it("clears listeners and is idempotent", () => { const ctrl = new AiCreditsController( - { cents: 1000, allowed: true }, + { cents: 1000, allowed: true, byok: false }, { fetcher: vi.fn<() => Promise>>(), router: fakeRouter, @@ -211,7 +272,7 @@ describe("AiCreditsController.dispose", () => { it("freezes state — consume() after dispose() does not mutate snapshot", () => { const ctrl = new AiCreditsController( - { cents: 1000, allowed: true }, + { cents: 1000, allowed: true, byok: false }, { fetcher: vi.fn<() => Promise>>(), router: fakeRouter, @@ -231,7 +292,7 @@ describe("AiCreditsController.dispose", () => { it("consume() after dispose() does not invoke router on failure envelopes", () => { const navigate = vi.fn<(href: string) => void>(); const ctrl = new AiCreditsController( - { cents: 1000, allowed: true }, + { cents: 1000, allowed: true, byok: false }, { fetcher: vi.fn<() => Promise>>(), router: fakeRouter, diff --git a/editor/lib/ai/credits/actions.ts b/editor/lib/ai/credits/actions.ts index 455a8c477d..87773785d0 100644 --- a/editor/lib/ai/credits/actions.ts +++ b/editor/lib/ai/credits/actions.ts @@ -14,7 +14,7 @@ * through `useAiCredits().refresh`. */ -import { withAiAuth, type AiActionResult } from "@/lib/ai/server"; +import { withAiAuth, isByokActive, type AiActionResult } from "@/lib/ai/server"; import { getEntitlement } from "@/lib/billing/metronome"; import { createClient } from "@/lib/supabase/server"; import { resolveSessionOrganizationId } from "@/lib/auth/organization"; @@ -22,9 +22,20 @@ import { resolveSessionOrganizationId } from "@/lib/auth/organization"; export type AiCreditsPreload = { cents: number | null; allowed: boolean; + /** + * Server-side BYOK key is set → billing is bypassed and the balance is + * meaningless. Instance-global (resolved at module load), so it is + * surfaced even on the unauth/no-org path. Only this boolean crosses + * to the client — never the key (GRIDA-SEC-003). + */ + byok: boolean; }; -const EMPTY: AiCreditsPreload = { cents: null, allowed: false }; +const EMPTY: AiCreditsPreload = { + cents: null, + allowed: false, + byok: isByokActive(), +}; /** * Cache-first read of balance + entitlement for an org. Called from a @@ -52,7 +63,7 @@ export async function preloadAiCredits( // surface as `null` so the chip renders "—" instead of "$0.00". const cents = ent.reason === "not_provisioned" ? null : ent.cachedBalanceCents; - return { cents, allowed: ent.allowed }; + return { cents, allowed: ent.allowed, byok: isByokActive() }; } /** diff --git a/editor/lib/ai/credits/controller.ts b/editor/lib/ai/credits/controller.ts index 47bd7734b0..ea54222631 100644 --- a/editor/lib/ai/credits/controller.ts +++ b/editor/lib/ai/credits/controller.ts @@ -29,6 +29,12 @@ export type AiCreditsState = { cents: number | null; /** Gate state (cached). `true` when above floor and entitled. */ allowed: boolean; + /** + * Server-side BYOK key is set → billing is bypassed; `cents`/`allowed` + * are meaningless and must not be shown as a balance. Static for the + * controller's lifetime (resolved server-side at module load). + */ + byok: boolean; }; export type ConsumeOptions = { @@ -98,7 +104,12 @@ export class AiCreditsController { const env = await this.fetcher(); if (this.disposed) return; if (env.success) { - this.set({ cents: env.data.balanceCents, allowed: true }); + // set() replaces state wholesale — spread to keep byok. + this.set({ + ...this.state, + cents: env.data.balanceCents, + allowed: true, + }); } } finally { this.inflightRefresh = null; @@ -131,7 +142,11 @@ export class AiCreditsController { ): AiActionData | undefined => { if (this.disposed) return undefined; if (env.success) { - this.set({ cents: env.data.balanceCents, allowed: true }); + this.set({ + ...this.state, + cents: env.data.balanceCents, + allowed: true, + }); return env.data; } const action = this.router(env, { next: opts?.next }); diff --git a/editor/lib/ai/credits/index.ts b/editor/lib/ai/credits/index.ts index 3741962486..6b0697d464 100644 --- a/editor/lib/ai/credits/index.ts +++ b/editor/lib/ai/credits/index.ts @@ -15,7 +15,7 @@ import * as format from "./format"; import { Provider } from "./provider"; -export { useAiCredits } from "./provider"; +export { useAiCredits, type AiCreditsView } from "./provider"; export { AiCreditsController, type AiCreditsState, diff --git a/editor/lib/ai/credits/provider.tsx b/editor/lib/ai/credits/provider.tsx index b190579a80..0c0ec161b1 100644 --- a/editor/lib/ai/credits/provider.tsx +++ b/editor/lib/ai/credits/provider.tsx @@ -41,18 +41,46 @@ export function Provider({ initial, children }: AiCreditsProviderProps) { return {children}; } +/** + * Self-harnessing view returned by `useAiCredits()`. + * + * Discriminated on `mode`. The `byok` variant **omits** `cents` / + * `allowed` / `formatted` on purpose: when a server-side BYOK key is + * set the balance is meaningless, so any consumer that wants to render + * a balance is forced by the compiler to first handle `mode === "byok"` + * and pick its own label (the label is per-call-site — there is no + * global glyph). `refresh`/`consume` exist in both variants so envelope + * folding still works (under BYOK they no-op the display). + */ +export type AiCreditsView = + | { + mode: "billed"; + cents: number | null; + allowed: boolean; + formatted: string | null; + formattedExact: string | null; + refresh: AiCreditsController["refresh"]; + consume: AiCreditsController["consume"]; + } + | { + mode: "byok"; + refresh: AiCreditsController["refresh"]; + consume: AiCreditsController["consume"]; + }; + /** * Hook accessor for the credits controller's reactive state. * * const credits = useAiCredits(); - * credits.cents // number | null - * credits.allowed // boolean - * credits.formatted // "$26.0" | null + * if (credits.mode === "byok") return ; + * credits.cents // number | null (billed only) + * credits.allowed // boolean (billed only) + * credits.formatted // "$26.0" | null (billed only) * credits.formattedExact // "$25.9921" | null * credits.refresh() // pull live * credits.consume(env, opts)// fold envelope; redirects auto-routed */ -export function useAiCredits() { +export function useAiCredits(): AiCreditsView { const ctrl = useContext(Ctx); if (!ctrl) { throw new Error( @@ -67,7 +95,11 @@ export function useAiCredits() { // `refresh` and `consume` are arrow properties on the controller // (see controller.ts) — pass them through unbound; identity is // stable across renders since they're set once in the constructor. + if (state.byok) { + return { mode: "byok", refresh: ctrl.refresh, consume: ctrl.consume }; + } return { + mode: "billed", cents: state.cents, allowed: state.allowed, formatted: format.chip(state.cents), diff --git a/editor/lib/ai/server.ts b/editor/lib/ai/server.ts index a0ad299509..c031fbb23d 100644 --- a/editor/lib/ai/server.ts +++ b/editor/lib/ai/server.ts @@ -138,6 +138,10 @@ export class MissingOrgIdError extends Error { } export { BillingMetronomeError }; +// GRIDA-SEC-003: re-exported through the seam so the credits module can +// read BYOK state via `@/lib/ai/server` without reaching past the seam +// into `./models`. Only the boolean crosses out — never the key. +export { isByokActive }; export type { ModelTier }; // =========================================================================== From 0b4bc8efc8d46fbb41995eadcc0368a5f3b94e7d Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 18 May 2026 18:55:32 +0900 Subject: [PATCH 3/6] test(fonts): stabilize STAT structure test timeout The "parses STAT table structure correctly" test inherited the 5000ms vitest default while synchronously parsing 4 large variable fonts, making it flake on slower CI runners (5084ms observed). Match its sibling multi-font tests with an explicit 20000ms timeout and drop a duplicate Recursive parse that added runtime but no coverage. --- packages/grida-fonts/__tests__/typr.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/grida-fonts/__tests__/typr.test.ts b/packages/grida-fonts/__tests__/typr.test.ts index f0ce904ca5..73c74212f6 100644 --- a/packages/grida-fonts/__tests__/typr.test.ts +++ b/packages/grida-fonts/__tests__/typr.test.ts @@ -119,7 +119,6 @@ describe("Typr font parsing", () => { const fonts = [ "Recursive/Recursive-VariableFont_CASL,CRSV,MONO,slnt,wght.ttf", "Roboto_Flex/RobotoFlex-VariableFont_GRAD,XOPQ,XTRA,YOPQ,YTAS,YTDE,YTFI,YTLC,YTUC,opsz,slnt,wdth,wght.ttf", - "Recursive/Recursive-VariableFont_CASL,CRSV,MONO,slnt,wght.ttf", "Geist/Geist-VariableFont_wght.ttf", ]; @@ -160,7 +159,7 @@ describe("Typr font parsing", () => { expect([1, 2, 3, 4]).toContain(value.format); }); }); - }); + }, 20000); it("parses fvar instances correctly", () => { const font = loadFont( From 3787b58dd8c13887e9e7ae818b40b63deffd7561 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 18 May 2026 19:03:39 +0900 Subject: [PATCH 4/6] test(fonts): set package-level testTimeout to fix Typr flake Per-test timeout bumps were whack-a-mole: after stabilizing the STAT test, "parses Geist variable font" then flaked at 5092ms. Any single large variable-font parse can exceed the 5000ms vitest default on a loaded CI runner, so raise it package-wide to 30000ms and drop the now-redundant per-test override. --- packages/grida-fonts/__tests__/typr.test.ts | 2 +- packages/grida-fonts/vitest.config.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/grida-fonts/__tests__/typr.test.ts b/packages/grida-fonts/__tests__/typr.test.ts index 73c74212f6..32e20e6173 100644 --- a/packages/grida-fonts/__tests__/typr.test.ts +++ b/packages/grida-fonts/__tests__/typr.test.ts @@ -159,7 +159,7 @@ describe("Typr font parsing", () => { expect([1, 2, 3, 4]).toContain(value.format); }); }); - }, 20000); + }); it("parses fvar instances correctly", () => { const font = loadFont( diff --git a/packages/grida-fonts/vitest.config.ts b/packages/grida-fonts/vitest.config.ts index 7ae0240ae8..36f16539fa 100644 --- a/packages/grida-fonts/vitest.config.ts +++ b/packages/grida-fonts/vitest.config.ts @@ -4,5 +4,9 @@ export default defineConfig({ test: { globals: true, setupFiles: ["./vitest.setup.ts"], + // Typr.parse() is a synchronous full-font parse; a single large + // variable font can take ~5s on a loaded CI runner, so the 5000ms + // vitest default flakes. See the perf follow-up task. + testTimeout: 30000, }, }); From 772b15a85f73ab4dae008bab8405775c8f4e8f7f Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 18 May 2026 19:34:51 +0900 Subject: [PATCH 5/6] fix(ai): scope BYOK billing-bypass to the AI-SDK text path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the PR #718 AI review (Codex P1/P2, CodeRabbit). P1 (silent drain): `withAiAuth`'s `balanceCents:0` short-circuit fired for every action whenever a BYOK key was set, but BYOK only swaps the AI-SDK provider — Replicate-backed actions (audio/image) still run `withTransaction` and bill. Those calls spent real credit and could 402 while the client was told "0 / unlimited". The short-circuit is now opt-gated (`byokBypass`, default `false`); only `ai/chat` (AI-SDK text) sets it. Billed actions read the real balance under BYOK. UI: dropped the global discriminated union — it wrongly forced a "BYOK" label onto Replicate-billed surfaces. `useAiCredits()` returns a flat `byok` flag; only the AI chat page acts on it. Music & image playgrounds reverted to the normal balance view. P2: BYOK keys are trimmed; whitespace-only falls back to the billed path (strengthens the documented fail-closed contract). Docs: SECURITY.md GRIDA-SEC-003 + billing.md reworded — BYOK bypass is the AI-SDK text path only; Replicate audio/image still gate+bill. billing.md outside-/docs links → GitHub URLs; added `format: md`. GRIDA-SEC-003: net hardening — narrows the bypass, never widens it; auth + the unconditional billed-path gate are untouched. --- SECURITY.md | 14 ++- docs/contributing/billing.md | 16 ++-- .../(playground)/playground/image/_page.tsx | 25 ++--- editor/app/(www)/(ai)/ai/_page.tsx | 6 +- .../(www)/(ai)/ai/music/playground/_page.tsx | 4 +- editor/lib/ai/__tests__/server.test.ts | 33 +++++-- editor/lib/ai/actions/chat.ts | 91 ++++++++++--------- editor/lib/ai/credits/index.ts | 2 +- editor/lib/ai/credits/provider.tsx | 48 +++------- editor/lib/ai/models.ts | 7 +- editor/lib/ai/server.ts | 33 +++++-- 11 files changed, 153 insertions(+), 126 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 237361b041..3871dd3fe5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -241,13 +241,19 @@ inputOrgId })`. It resolves from: route param slug → request **BYOK carve-out (intentional).** When a contributor sets a `BYOK_*` key ([editor/lib/ai/models.ts](editor/lib/ai/models.ts) — `BYOK_OPENROUTER_API_KEY`, `BYOK_AI_GATEWAY_API_KEY`), `grida`/`model` -return a **bare** provider and the billing seam is bypassed entirely: -no gate, no Metronome ingest, no balance read, **and** the +return a **bare** provider so the **AI-SDK text/chat path** bypasses +the billing seam: no gate, no Metronome ingest, **and** the `MissingOrgIdError` runtime contract above does not fire (a bare provider has no middleware). The contributor's own provider key is charged directly — there is no Grida balance, hence no victim to -drain, so the billing trust boundary is moot for that path. **BYOK -bypasses billing only — never auth.** `requireOrganizationId` and +drain, so the billing trust boundary is moot for that path. **Scope — +AI-SDK path only.** BYOK only swaps the AI-SDK provider; Replicate- +backed actions (`runPrediction`/`withTransaction` — audio, image) are +**not** bypassed and still gate + ingest under BYOK. Accordingly the +`withAiAuth` `balanceCents:0` short-circuit is opt-gated +(`byokBypass`, default `false`): only AI-SDK actions set it, so billed +actions still read the real balance and cannot silently drain credit +while reporting `0`. **BYOK bypasses billing only — never auth.** `requireOrganizationId` and route/action auth always run, so a logged-in user with no resolvable org is still rejected. Gated solely by server-only, non-`NEXT_PUBLIC_` env vars never set in the hosted product (same trust model as diff --git a/docs/contributing/billing.md b/docs/contributing/billing.md index 71010c43db..631a813dab 100644 --- a/docs/contributing/billing.md +++ b/docs/contributing/billing.md @@ -1,3 +1,7 @@ +--- +format: md +--- + # Contributing to Grida | Billing Setup guide for contributors working on the billing surface. Two clouds to wire: @@ -11,7 +15,7 @@ Setup guide for contributors working on the billing surface. Two clouds to wire: ## Just need AI to work? BYOK instead (no billing setup) -If you are **not** working on the billing surface and only need AI features (canvas agent, chat) to run locally, skip the entire Metronome / Stripe / tunnel setup below. Set a contributor **BYOK** key and the AI seam routes through your own provider with the billing seam **bypassed entirely** — no credit gate, no metering, no Metronome. +If you are **not** working on the billing surface and only need the **AI chat / canvas agent** (text) to run locally, skip the entire Metronome / Stripe / tunnel setup below. Set a contributor **BYOK** key and the **AI-SDK text path** routes through your own provider with billing bypassed — no credit gate, no metering, no Metronome. ```bash # editor/.env.local (gitignored) @@ -21,9 +25,9 @@ BYOK_OPENROUTER_API_KEY=sk-or-v1-... # https://openrouter.ai/keys ``` - Bypasses **billing only — never auth.** Still sign in (`insider@grida.co` / `password`); a resolvable org is still required (an unauthenticated request still 401s). -- **Text/chat only** — OpenRouter exposes no image/audio models. Catalog model IDs are unchanged; use IDs your provider accepts (edit `editor/lib/ai/models.ts` locally if one 404s). -- Precedence if both are set: OpenRouter, then AI Gateway. Fail-closed — an empty/unset key falls back to the billed path. -- **Never set `BYOK_*` on a hosted or preview deploy.** It disables billing **and** the org-id sanity gate for every org. Contributor / self-host / local only. See [SECURITY.md](../../SECURITY.md) `GRIDA-SEC-003` (BYOK carve-out). +- **Text/chat only** — BYOK swaps the AI-SDK provider, so only the text path is unbilled. Image/audio go through Replicate (`withTransaction`) and **still gate + bill even under BYOK** — those features need the full billing setup. (OpenRouter also exposes no image/audio models.) Catalog model IDs are unchanged; use IDs your provider accepts (edit `editor/lib/ai/models.ts` locally if one 404s). +- Precedence if both are set: OpenRouter, then AI Gateway. Fail-closed — an empty/unset (or whitespace-only) key falls back to the billed path. +- **Never set `BYOK_*` on a hosted or preview deploy.** It disables billing **and** the org-id sanity gate for every org. Contributor / self-host / local only. See [SECURITY.md](https://github.com/gridaco/grida/blob/main/SECURITY.md) (`GRIDA-SEC-003`, BYOK carve-out). Working on billing itself? Ignore BYOK and continue with the full setup below. @@ -102,7 +106,7 @@ cloudflared tunnel create grida-webhooks cloudflared tunnel route dns grida-webhooks # e.g. metronome-dev.yourdomain.co ``` -Create `~/.cloudflared/grida-webhooks.yml` (path filter is the security boundary — see [SECURITY.md](../../SECURITY.md) `GRIDA-SEC-001`): +Create `~/.cloudflared/grida-webhooks.yml` (path filter is the security boundary — see [SECURITY.md](https://github.com/gridaco/grida/blob/main/SECURITY.md) `GRIDA-SEC-001`): ```yaml tunnel: grida-webhooks @@ -168,7 +172,7 @@ See the suite's own README for the contract. - **Service module**: `editor/lib/billing/metronome.ts` — `provisionOrg`, `addStripeChargedCommit`, `setAutoReload`, `getEntitlement`, `ingestUsageEvent`. - **`grida_billing.account.provisioning_uid`**: per-account UUID composed into Metronome aliases. `supabase db reset` produces fresh aliases — any orphan Metronome customers from previous instances are inert. No manual cleanup needed. -User-facing billing copy: [`docs/platform/billing.mdx`](../platform/billing.mdx). Design notes: [`docs/wg/platform/billing/`](../wg/platform/billing/) (AI credits master plan, Metronome integration, known issues). CLI guide: [`editor/scripts/billing/README.md`](../../editor/scripts/billing/README.md). +User-facing billing copy: [`docs/platform/billing.mdx`](../platform/billing.mdx). Design notes: [`docs/wg/platform/billing/`](../wg/platform/billing/) (AI credits master plan, Metronome integration, known issues). CLI guide: [`editor/scripts/billing/README.md`](https://github.com/gridaco/grida/blob/main/editor/scripts/billing/README.md). --- diff --git a/editor/app/(tools)/(playground)/playground/image/_page.tsx b/editor/app/(tools)/(playground)/playground/image/_page.tsx index d8539c4ce1..70b862cf8d 100644 --- a/editor/app/(tools)/(playground)/playground/image/_page.tsx +++ b/editor/app/(tools)/(playground)/playground/image/_page.tsx @@ -216,23 +216,18 @@ function BudgetBadge({ credits: ReturnType; className?: string; }) { - const shell = (content: React.ReactNode) => ( -
- {content} -
- ); - // BYOK: no balance to show — own key pays. - if (credits.mode === "byok") { - return shell("BYOK"); - } return ( - {shell(credits.formatted ?? "—")} + +
+ {credits.formatted ?? "—"} +
+
{credits.formattedExact ?? "—"} balance diff --git a/editor/app/(www)/(ai)/ai/_page.tsx b/editor/app/(www)/(ai)/ai/_page.tsx index e4b60f7937..030017db1a 100644 --- a/editor/app/(www)/(ai)/ai/_page.tsx +++ b/editor/app/(www)/(ai)/ai/_page.tsx @@ -167,7 +167,9 @@ function CreditChip({ onRefresh: () => void; refreshing: boolean; }) { - if (credits.mode === "byok") { + // BYOK bypasses billing for this AI-SDK chat surface only — no Grida + // spend, so balance is moot here. GRIDA-SEC-003. + if (credits.byok) { return ( BYOK @@ -475,7 +477,7 @@ export default function Page({ authed, context }: Props) { {context && - credits.mode === "billed" && + !credits.byok && !credits.allowed && credits.cents !== null && billingHref && ( diff --git a/editor/app/(www)/(ai)/ai/music/playground/_page.tsx b/editor/app/(www)/(ai)/ai/music/playground/_page.tsx index 85c04c62e4..4616b7d66c 100644 --- a/editor/app/(www)/(ai)/ai/music/playground/_page.tsx +++ b/editor/app/(www)/(ai)/ai/music/playground/_page.tsx @@ -181,9 +181,7 @@ function Workspace() {
- {credits.mode === "byok" - ? "BYOK" - : `${credits.formatted ?? "—"} left`} + {credits.formatted ?? "—"} left { expect(mockedRefreshBalance).not.toHaveBeenCalled(); }); - it("under BYOK: skips refreshBalance, returns balanceCents:0, auth still enforced", async () => { + it("under BYOK + byokBypass (AI-SDK path): skips refreshBalance, balanceCents:0, auth still enforced", async () => { mockedIsByokActive.mockReturnValue(true); stubAuthed(7); - const result = await withAiAuth("test/scope", undefined, async (orgId) => ({ - reply: "byok", - org: orgId, - })); + const result = await withAiAuth( + "test/scope", + undefined, + async (orgId) => ({ reply: "byok", org: orgId }), + { byokBypass: true } + ); expect(result).toEqual({ success: true, @@ -305,9 +307,28 @@ describe("withAiAuth envelope", () => { // BYOK bypasses billing only — auth + org resolution still ran. expect(mockedCreateLibraryClient).toHaveBeenCalled(); expect(mockedRequireOrganizationId).toHaveBeenCalled(); - // The Metronome balance read is skipped. expect(mockedRefreshBalance).not.toHaveBeenCalled(); }); + + it("under BYOK without byokBypass (Replicate-billed path): still reads the real balance", async () => { + // GRIDA-SEC-003: BYOK only bypasses the AI-SDK provider. A billed + // action must report the real balance under BYOK, never a false 0, + // or it silently drains credit and surprises with a 402. + mockedIsByokActive.mockReturnValue(true); + stubAuthed(7); + mockedRefreshBalance.mockResolvedValueOnce({ cents: 2599, live: null }); + + const result = await withAiAuth("test/scope", undefined, async (orgId) => ({ + reply: "billed", + org: orgId, + })); + + expect(result).toEqual({ + success: true, + data: { reply: "billed", org: 7, balanceCents: 2599 }, + }); + expect(mockedRefreshBalance).toHaveBeenCalledWith(7); + }); }); describe("checkGate", () => { diff --git a/editor/lib/ai/actions/chat.ts b/editor/lib/ai/actions/chat.ts index ae1b9f862a..90ae265fe9 100644 --- a/editor/lib/ai/actions/chat.ts +++ b/editor/lib/ai/actions/chat.ts @@ -96,51 +96,58 @@ export async function runChat(input: RunChatInput): Promise { }; } - return withAiAuth("ai/chat", input.organizationId, async (orgId) => { - const requested = input.model_id; - const useModelId = requested && isCatalogId(requested) ? requested : null; - const languageModel = useModelId ? grida(useModelId) : model("mini"); - // Resolve the catalog id from the same source as `model("mini")` so - // a tier remap can't desync the billed cost card from the SDK call. - const resolvedId = useModelId ?? catalog[tiers.mini].id; + return withAiAuth( + "ai/chat", + input.organizationId, + async (orgId) => { + const requested = input.model_id; + const useModelId = requested && isCatalogId(requested) ? requested : null; + const languageModel = useModelId ? grida(useModelId) : model("mini"); + // Resolve the catalog id from the same source as `model("mini")` so + // a tier remap can't desync the billed cost card from the SDK call. + const resolvedId = useModelId ?? catalog[tiers.mini].id; - const messages: Array<{ - role: "system" | "user" | "assistant"; - content: string; - }> = [ - { role: "system", content: SYSTEM_PROMPT }, - ...input.history.map((t) => ({ role: t.role, content: t.content })), - { role: "user", content: input.message }, - ]; + const messages: Array<{ + role: "system" | "user" | "assistant"; + content: string; + }> = [ + { role: "system", content: SYSTEM_PROMPT }, + ...input.history.map((t) => ({ role: t.role, content: t.content })), + { role: "user", content: input.message }, + ]; - const { text, usage } = await generateText({ - model: languageModel, - messages, - providerOptions: { - grida: { - organizationId: orgId, - feature: "ai/chat", + const { text, usage } = await generateText({ + model: languageModel, + messages, + providerOptions: { + grida: { + organizationId: orgId, + feature: "ai/chat", + }, }, - }, - }); + }); - const providerUsage = toProviderUsage(usage); - const realCostUsd = costUsdFromTokenUsage(resolvedId, providerUsage); - const costMills = costMillsFromTokenUsage(resolvedId, providerUsage); + const providerUsage = toProviderUsage(usage); + const realCostUsd = costUsdFromTokenUsage(resolvedId, providerUsage); + const costMills = costMillsFromTokenUsage(resolvedId, providerUsage); - return { - reply: text, - model_id: resolvedId, - costMills, - realCostUsd, - usage: { - inputTokens: providerUsage.inputTokens.total ?? 0, - outputTokens: providerUsage.outputTokens.total ?? 0, - totalTokens: - (providerUsage.inputTokens.total ?? 0) + - (providerUsage.outputTokens.total ?? 0), - cacheReadTokens: providerUsage.inputTokens.cacheRead, - }, - } satisfies RunChatData; - }); + return { + reply: text, + model_id: resolvedId, + costMills, + realCostUsd, + usage: { + inputTokens: providerUsage.inputTokens.total ?? 0, + outputTokens: providerUsage.outputTokens.total ?? 0, + totalTokens: + (providerUsage.inputTokens.total ?? 0) + + (providerUsage.outputTokens.total ?? 0), + cacheReadTokens: providerUsage.inputTokens.cacheRead, + }, + } satisfies RunChatData; + }, + // GRIDA-SEC-003: AI-SDK text path — BYOK swaps in a bare provider + // with no billing middleware, so under BYOK there is no Grida spend. + { byokBypass: true } + ); } diff --git a/editor/lib/ai/credits/index.ts b/editor/lib/ai/credits/index.ts index 6b0697d464..3741962486 100644 --- a/editor/lib/ai/credits/index.ts +++ b/editor/lib/ai/credits/index.ts @@ -15,7 +15,7 @@ import * as format from "./format"; import { Provider } from "./provider"; -export { useAiCredits, type AiCreditsView } from "./provider"; +export { useAiCredits } from "./provider"; export { AiCreditsController, type AiCreditsState, diff --git a/editor/lib/ai/credits/provider.tsx b/editor/lib/ai/credits/provider.tsx index 0c0ec161b1..2381c26e40 100644 --- a/editor/lib/ai/credits/provider.tsx +++ b/editor/lib/ai/credits/provider.tsx @@ -41,46 +41,25 @@ export function Provider({ initial, children }: AiCreditsProviderProps) { return {children}; } -/** - * Self-harnessing view returned by `useAiCredits()`. - * - * Discriminated on `mode`. The `byok` variant **omits** `cents` / - * `allowed` / `formatted` on purpose: when a server-side BYOK key is - * set the balance is meaningless, so any consumer that wants to render - * a balance is forced by the compiler to first handle `mode === "byok"` - * and pick its own label (the label is per-call-site — there is no - * global glyph). `refresh`/`consume` exist in both variants so envelope - * folding still works (under BYOK they no-op the display). - */ -export type AiCreditsView = - | { - mode: "billed"; - cents: number | null; - allowed: boolean; - formatted: string | null; - formattedExact: string | null; - refresh: AiCreditsController["refresh"]; - consume: AiCreditsController["consume"]; - } - | { - mode: "byok"; - refresh: AiCreditsController["refresh"]; - consume: AiCreditsController["consume"]; - }; - /** * Hook accessor for the credits controller's reactive state. * * const credits = useAiCredits(); - * if (credits.mode === "byok") return ; - * credits.cents // number | null (billed only) - * credits.allowed // boolean (billed only) - * credits.formatted // "$26.0" | null (billed only) + * credits.cents // number | null + * credits.allowed // boolean + * credits.formatted // "$26.0" | null * credits.formattedExact // "$25.9921" | null + * credits.byok // a BYOK key is set server-side * credits.refresh() // pull live * credits.consume(env, opts)// fold envelope; redirects auto-routed + * + * `byok` reports only that a key is configured. BYOK bypasses billing + * for the AI-SDK text/chat path ONLY — Replicate-backed surfaces + * (audio/image) still bill, so `cents`/`allowed` stay truthful and + * those surfaces must NOT treat `byok` as "unlimited". Only the AI + * chat surface acts on this flag. GRIDA-SEC-003. */ -export function useAiCredits(): AiCreditsView { +export function useAiCredits() { const ctrl = useContext(Ctx); if (!ctrl) { throw new Error( @@ -95,15 +74,12 @@ export function useAiCredits(): AiCreditsView { // `refresh` and `consume` are arrow properties on the controller // (see controller.ts) — pass them through unbound; identity is // stable across renders since they're set once in the constructor. - if (state.byok) { - return { mode: "byok", refresh: ctrl.refresh, consume: ctrl.consume }; - } return { - mode: "billed", cents: state.cents, allowed: state.allowed, formatted: format.chip(state.cents), formattedExact: format.exact(state.cents), + byok: state.byok, refresh: ctrl.refresh, consume: ctrl.consume, }; diff --git a/editor/lib/ai/models.ts b/editor/lib/ai/models.ts index 7193e38e3a..7a65ee883d 100644 --- a/editor/lib/ai/models.ts +++ b/editor/lib/ai/models.ts @@ -271,13 +271,14 @@ export const gateway = createGateway({ // billing ONLY, never auth (requireOrganizationId still runs). Gated // solely by server-only env vars NEVER set in the hosted product (same // trust model as OPENAI_API_KEY / REPLICATE_API_TOKEN). Fail-closed: -// active only when a key env var is a non-empty string. +// active only when a key env var is a non-empty string after trim +// (whitespace-only secrets fall back to the billed path). // // Implementations (precedence: OpenRouter first, then AI Gateway). A // third BYOK key is a new branch here — no registry. // --------------------------------------------------------------------------- function resolveByokProvider() { - const openrouterKey = process.env.BYOK_OPENROUTER_API_KEY; + const openrouterKey = process.env.BYOK_OPENROUTER_API_KEY?.trim(); if (openrouterKey) { return createOpenAICompatible({ name: "openrouter", @@ -286,7 +287,7 @@ function resolveByokProvider() { headers: { "HTTP-Referer": "https://grida.co", "X-Title": "Grida" }, }); } - const aiGatewayKey = process.env.BYOK_AI_GATEWAY_API_KEY; + const aiGatewayKey = process.env.BYOK_AI_GATEWAY_API_KEY?.trim(); if (aiGatewayKey) { return createGateway({ apiKey: aiGatewayKey, diff --git a/editor/lib/ai/server.ts b/editor/lib/ai/server.ts index c031fbb23d..e155887321 100644 --- a/editor/lib/ai/server.ts +++ b/editor/lib/ai/server.ts @@ -727,6 +727,19 @@ export type WithAiAuthOptions = { * (e.g. backfill jobs, internal-only routes). */ balance?: boolean; + /** + * When `true` AND a BYOK key is set, this action runs on the BYOK + * bare provider (AI-SDK text path) which has no billing middleware — + * there is genuinely no Grida spend, so the post-call balance read is + * skipped and `balanceCents` is reported as `0`. + * + * Safe default `false`: BYOK only swaps the AI-SDK provider, NOT the + * Replicate `withTransaction` path. Actions that may bill through + * Replicate (audio/image) must leave this unset so they still read + * the real balance under BYOK — otherwise they silently drain credit + * while reporting `0`. GRIDA-SEC-003. + */ + byokBypass?: boolean; }; /** @@ -738,22 +751,24 @@ export type WithAiAuthOptions = { * Metronome read post-fn). The `withTransaction` middleware defaults to * `awaitIngest:true` so the post-fn read sees the reconciled value. * - * BYOK (contributor-only): auth + org lookup still run — BYOK never - * bypasses auth. `fn` executes against a bare provider so the billing - * middleware is skipped entirely; the post-fn Metronome balance read is - * also skipped and balance is reported as `0`. GRIDA-SEC-003. + * BYOK (contributor-only): auth + org lookup always run — BYOK never + * bypasses auth. The balance short-circuit (`balanceCents:0`, no + * Metronome read) fires only when the caller passes `byokBypass:true`, + * i.e. the action runs on the AI-SDK bare provider with no billing + * middleware. Replicate-billed actions omit it and still read the real + * balance under BYOK (they genuinely spend). GRIDA-SEC-003. */ export function withAiAuth>( scope: string, inputOrgId: number | string | undefined, fn: (orgId: number) => Promise, - opts: { balance: false } + opts: { balance: false; byokBypass?: boolean } ): Promise>; export function withAiAuth>( scope: string, inputOrgId: number | string | undefined, fn: (orgId: number) => Promise, - opts?: { balance?: true } + opts?: { balance?: true; byokBypass?: boolean } ): Promise>; export async function withAiAuth>( scope: string, @@ -789,8 +804,10 @@ export async function withAiAuth>( if (opts.balance === false) { return { success: true, data }; } - if (isByokActive()) { - // GRIDA-SEC-003 BYOK carve-out: no Grida balance to read. + if (isByokActive() && opts.byokBypass) { + // GRIDA-SEC-003 BYOK carve-out: AI-SDK path has no billing + // middleware, so there is no Grida balance to read. Replicate + // actions omit `byokBypass` and fall through to the real read. return { success: true, data: { ...data, balanceCents: 0 } as AiActionData, From 786e8a96f04dd7a34eb56c40eea2e93749b5d949 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 18 May 2026 19:51:23 +0900 Subject: [PATCH 6/6] fix(ai): finish BYOK text-only scope + harden chat model allowlist CodeRabbit follow-up on 772b15a85: - grida.imageModel() stays on the billing-wrapped provider; BYOK swaps only the language provider, so SDK image generation is still gated + metered under BYOK (matches SECURITY.md / billing.md GRIDA-SEC-003). - runChat only accepts the 4 tier model ids (was: any catalog id), so a forged payload can't request reserved/non-tiered models. --- editor/lib/ai/actions/chat.ts | 17 ++++++++++++----- editor/lib/ai/server.ts | 14 ++++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/editor/lib/ai/actions/chat.ts b/editor/lib/ai/actions/chat.ts index 90ae265fe9..12da2131b3 100644 --- a/editor/lib/ai/actions/chat.ts +++ b/editor/lib/ai/actions/chat.ts @@ -14,7 +14,7 @@ import { type AiActionResult, type ProviderUsage, } from "@/lib/ai/server"; -import { catalog, tiers, type CatalogId } from "@/lib/ai/models"; +import { catalog, tiers } from "@/lib/ai/models"; export type ChatTurn = { role: "user" | "assistant"; content: string }; @@ -58,9 +58,15 @@ export type RunChatResponse = AiActionResult; const SYSTEM_PROMPT = "You are a concise assistant inside the Grida editor. Reply in plain text — no markdown, no code fences. Keep answers short unless the user asks for detail."; -function isCatalogId(id: string): id is CatalogId { - return id in catalog; -} +// Server-side allowlist: only the tier-backed models the picker +// exposes. `isCatalogId` (any catalog entry) let a forged payload +// select reserved / non-tiered models — restrict to the 4 tiers. +const ALLOWED_CHAT_MODEL_IDS = new Set([ + catalog[tiers.nano].id, + catalog[tiers.mini].id, + catalog[tiers.pro].id, + catalog[tiers.max].id, +]); /** * Coerce the AI SDK result.usage (whatever shape the provider returns) @@ -101,7 +107,8 @@ export async function runChat(input: RunChatInput): Promise { input.organizationId, async (orgId) => { const requested = input.model_id; - const useModelId = requested && isCatalogId(requested) ? requested : null; + const useModelId = + requested && ALLOWED_CHAT_MODEL_IDS.has(requested) ? requested : null; const languageModel = useModelId ? grida(useModelId) : model("mini"); // Resolve the catalog id from the same source as `model("mini")` so // a tier remap can't desync the billed cost card from the SDK call. diff --git a/editor/lib/ai/server.ts b/editor/lib/ai/server.ts index e155887321..132d1ec5e9 100644 --- a/editor/lib/ai/server.ts +++ b/editor/lib/ai/server.ts @@ -440,17 +440,19 @@ export type GridaProvider = ((modelId: string) => LanguageModel) & { * grida.languageModel("openai/...") // → LanguageModel (explicit) * grida.imageModel("bfl/flux-2-pro") // → ImageModel */ -// Resolve the active provider once: the BYOK bare provider when a -// contributor key is set, else the billing-wrapped gateway. -const activeProvider = byok ?? wrappedProvider; +// GRIDA-SEC-003: BYOK is text-path-only. Only the LANGUAGE provider +// swaps to the bare BYOK provider (no billing middleware). Image models +// stay on the billing-wrapped provider so SDK image generation is still +// gated + metered even under BYOK — matching SECURITY.md / billing.md. +const activeLanguageProvider = byok ?? wrappedProvider; function gridaFn(modelId: string): LanguageModel { - return activeProvider.languageModel(modelId); + return activeLanguageProvider.languageModel(modelId); } gridaFn.languageModel = (modelId: string): LanguageModel => - activeProvider.languageModel(modelId); + activeLanguageProvider.languageModel(modelId); gridaFn.imageModel = (modelId: string): ImageModel => - activeProvider.imageModel(modelId); + wrappedProvider.imageModel(modelId); export const grida: GridaProvider = gridaFn;