feat(workbench): uttryckligt pilotläge utan credit-plånbok #432

Merged
admin merged 1 commits from feat/pilot-credits-mode into main 2026-11-02 14:34:22 +00:00
3 changed files with 101 additions and 71 deletions
+5
View File
@@ -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=
+93 -71
View File
@@ -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(
+3
View File
@@ -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