|
|
|
@@ -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(
|
|
|
|
|