feat(workbench): explicit pilot mode without a credit wallet
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / security (push) Successful in 16s
Security Scan (SIAX Cloud) / security (push) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
masterplan-lock / check (pull_request) Successful in 23s
CI (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / quality (push) Successful in 48s
CI (SIAX Cloud) / integration (pull_request) Successful in 54s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m12s
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / security (push) Successful in 16s
Security Scan (SIAX Cloud) / security (push) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
masterplan-lock / check (pull_request) Successful in 23s
CI (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / quality (push) Successful in 48s
CI (SIAX Cloud) / integration (pull_request) Successful in 54s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m12s
OPT_CREDITS_MODE=off skips reserve/settle and reports credits:null, so the document flow can be piloted before prices are decided. It is never a silent fallback and purchases remain disabled.
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user