diff --git a/.env.example b/.env.example index 9d321a7..ef01df2 100644 --- a/.env.example +++ b/.env.example @@ -260,3 +260,8 @@ OPT_MODEL_MAX_OUTPUT_TOKENS=4000 # PRICE_BOOK_NOT_CONFIGURED. # {"version":"2026-11-01","currency":"SEK","packs":[{"id":"starter","credits":100,"amountMinor":9900}],"metering":{"pagesPerCredit":5,"inputTokensPerCredit":20000,"outputTokensPerCredit":4000}} OPT_CREDIT_PRICE_BOOK= + +# Pilot switch. `off` runs document structuring without a wallet; it is +# explicit (never a silent fallback), the response reports `credits: null`, and +# purchases stay disabled. Unset/`on` requires the price book + ledger. +OPT_CREDITS_MODE= diff --git a/app/api/0pt/documents/structure/route.ts b/app/api/0pt/documents/structure/route.ts index e823d41..f20ea52 100644 --- a/app/api/0pt/documents/structure/route.ts +++ b/app/api/0pt/documents/structure/route.ts @@ -127,9 +127,16 @@ export async function POST(request: NextRequest) { { status: 503 }, ); } - const priceBook = readCreditPriceBook(); - const databaseUrl = repositoryDatabaseUrl(); - if (!priceBook || !databaseUrl) { + // Explicit pilot switch: `OPT_CREDITS_MODE=off` runs structuring without a + // wallet. It is deliberately NOT a silent fallback — the response reports + // `credits: null` and the operator has named the mode. + const creditsEnabled = + process.env.OPT_CREDITS_MODE !== 'off' && + Boolean(readCreditPriceBook()) && + Boolean(repositoryDatabaseUrl()); + const priceBook = creditsEnabled ? readCreditPriceBook() : undefined; + const databaseUrl = creditsEnabled ? repositoryDatabaseUrl() : undefined; + if (!creditsEnabled && process.env.OPT_CREDITS_MODE !== 'off') { return NextResponse.json( { error: 'CREDITS_NOT_CONFIGURED', @@ -249,34 +256,40 @@ export async function POST(request: NextRequest) { ); } - const maximumCredits = creditsForUsage(priceBook, { - pages: Math.max(1, Math.ceil(sourceChars / 1800)), - inputTokens: estimatedInputTokens, - outputTokens: policy.maxOutputTokens, - }); + const maximumCredits = + creditsEnabled && priceBook + ? creditsForUsage(priceBook, { + pages: Math.max(1, Math.ceil(sourceChars / 1800)), + inputTokens: estimatedInputTokens, + outputTokens: policy.maxOutputTokens, + }) + : 0; const reservationId = crypto.randomUUID(); - const ledger = new PostgresCreditLedger(getRepositorySql(databaseUrl)); - try { - await ledger.apply({ - tenantId, - idempotencyKey: `reserve:${reservationId}`, - kind: 'reserve', - reference: reservationId, - amount: maximumCredits, - }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Credit reservation failed'; - if (/Insufficient available credits/i.test(message)) { - return NextResponse.json( - { - error: 'INSUFFICIENT_CREDITS', - message: 'Not enough credits for this job', - required_credits: maximumCredits, - }, - { status: 402 }, - ); + const ledger = + creditsEnabled && databaseUrl ? new PostgresCreditLedger(getRepositorySql(databaseUrl)) : undefined; + if (ledger) { + try { + await ledger.apply({ + tenantId, + idempotencyKey: `reserve:${reservationId}`, + kind: 'reserve', + reference: reservationId, + amount: maximumCredits, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Credit reservation failed'; + if (/Insufficient available credits/i.test(message)) { + return NextResponse.json( + { + error: 'INSUFFICIENT_CREDITS', + message: 'Not enough credits for this job', + required_credits: maximumCredits, + }, + { status: 402 }, + ); + } + return NextResponse.json({ error: 'CREDITS_UNAVAILABLE', message }, { status: 503 }); } - return NextResponse.json({ error: 'CREDITS_UNAVAILABLE', message }, { status: 503 }); } try { @@ -292,7 +305,7 @@ export async function POST(request: NextRequest) { dataClass, maxInputTokens: policy.maxInputTokens, maxOutputTokens: policy.maxOutputTokens, - maximumCredits, + maximumCredits: Math.max(1, maximumCredits), }, policy: { tenantId, @@ -304,13 +317,15 @@ export async function POST(request: NextRequest) { maxInputTokens: policy.maxInputTokens, maxOutputTokens: policy.maxOutputTokens, }, - reservation: { - tenantId, - projectId, - jobId: `doc:${documentId}:${correlationId}`, - status: 'reserved', - credits: maximumCredits, - }, + reservation: ledger + ? { + tenantId, + projectId, + jobId: `doc:${documentId}:${correlationId}`, + status: 'reserved', + credits: maximumCredits, + } + : undefined, }, countTokens: async (messages) => Math.ceil(JSON.stringify(messages).length / 4), instructions: instruction, @@ -318,29 +333,34 @@ export async function POST(request: NextRequest) { sources: sources.sources, }); - const charged = Math.min( - maximumCredits, - creditsForUsage(priceBook, { - pages: Math.max(1, Math.ceil(sourceChars / 1800)), - inputTokens: result.model.promptTokens, - outputTokens: result.model.completionTokens, - }), - ); + const charged = + ledger && priceBook + ? Math.min( + maximumCredits, + creditsForUsage(priceBook, { + pages: Math.max(1, Math.ceil(sourceChars / 1800)), + inputTokens: result.model.promptTokens, + outputTokens: result.model.completionTokens, + }), + ) + : 0; let balance; - try { - balance = ( - await ledger.apply({ - tenantId, - idempotencyKey: `settle:${reservationId}`, - kind: 'settle', - reference: reservationId, - amount: charged, - }) - ).balance; - } catch { - // The model result is real; keep the reservation for reconciliation - // rather than reporting a balance we could not write. - balance = undefined; + if (ledger) { + try { + balance = ( + await ledger.apply({ + tenantId, + idempotencyKey: `settle:${reservationId}`, + kind: 'settle', + reference: reservationId, + amount: charged, + }) + ).balance; + } catch { + // The model result is real; keep the reservation for reconciliation + // rather than reporting a balance we could not write. + balance = undefined; + } } if (result.status !== 'mechanically_validated') { @@ -349,7 +369,7 @@ export async function POST(request: NextRequest) { status: 'needs_review', issues: result.issues, model: result.model, - credits: { reserved: maximumCredits, charged, balance }, + credits: ledger ? { reserved: maximumCredits, charged, balance } : null, }, { status: 200 }, ); @@ -378,7 +398,7 @@ export async function POST(request: NextRequest) { value: result.value, evidence: result.evidence, model: result.model, - credits: { reserved: maximumCredits, charged, balance }, + credits: ledger ? { reserved: maximumCredits, charged, balance } : null, export: exported.status === 'ready' ? { media_type: exported.mediaType, content: exported.content } @@ -399,16 +419,18 @@ export async function POST(request: NextRequest) { { status: 202 }, ); } - try { - await ledger.apply({ - tenantId, - idempotencyKey: `release:${reservationId}`, - kind: 'release', - reference: reservationId, - amount: 0, - }); - } catch { - /* the reservation stays for reconciliation */ + if (ledger) { + try { + await ledger.apply({ + tenantId, + idempotencyKey: `release:${reservationId}`, + kind: 'release', + reference: reservationId, + amount: 0, + }); + } catch { + /* the reservation stays for reconciliation */ + } } const message = error instanceof Error ? error.message : 'Structuring failed'; return NextResponse.json( diff --git a/docs/guides/document-workbench.md b/docs/guides/document-workbench.md index a5c7469..bc41d98 100644 --- a/docs/guides/document-workbench.md +++ b/docs/guides/document-workbench.md @@ -35,6 +35,9 @@ UI: `/portal/dokument` (upload, structure, review, download) and `/portal/kredit releasing it. 6. **No price is ever read from the client.** The price book is a versioned, server-owned JSON document (`OPT_CREDIT_PRICE_BOOK`). +7. **Pilot mode is explicit.** `OPT_CREDITS_MODE=off` skips reserve/settle and + reports `credits: null`. It is not a silent fallback and it does not enable + purchases; unset/`on` requires the price book and the ledger. ## Reusable packages