Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/app/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ function providerRecoveryMessage(providerError) {
wait_then_retry: "Wait for the provider rate limit to clear, then retry deliberately.",
choose_model: "Choose a model that exists for this provider.",
retry_destination: "Retry the affected destination; successful destinations remain unchanged.",
reduce_destinations: "Reduce the number of destinations or retry only the affected destination.",
retry_or_choose_model: "Retry once, then choose another model if the response contract still fails.",
choose_provider: "Choose a supported provider route.",
retry_or_contact_owner: "Retry once. If it persists, inspect owner/server diagnostics using the correlation ID.",
Expand Down
51 changes: 41 additions & 10 deletions frontend/lib/ai/generateStudioPackage.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { evaluateStrategyQuality, STRATEGY_QUALITY_STATES } from "./strategyQual
import { duplicateRevisionTargets } from "./crossChannelQuality.mjs";
import { normalizeProviderError, providerErrorPayload } from "./providerErrors.mjs";
import { mapWithConcurrency } from "./generationConcurrency.mjs";
import {
createGenerationExecutionBudget,
estimateGenerationRequestBudget,
generationRequestBudgetError,
} from "./generationExecutionBudget.mjs";
import {
CHANNEL_CONTRACTS,
assessChannelDraft,
Expand Down Expand Up @@ -181,7 +186,7 @@ async function generateDestination({
provider,
prompt: buildChannelPrompt({ channel, context, campaignBrief }),
modelOverride,
config,
config: { ...config, requestKind: "destination_initial", destination: channel },
});
firstDraft = normalizeDestinationDraft(firstRaw, channel, generationInputs);
firstQuality = assessChannelDraft(channel, firstDraft, {
Expand Down Expand Up @@ -215,7 +220,7 @@ async function generateDestination({
qualityIssues: firstQuality.issues,
}),
modelOverride,
config,
config: { ...config, requestKind: "destination_revision", destination: channel },
});
const revisedDraft = normalizeDestinationDraft(revisedRaw, channel, generationInputs);
const revisedQuality = assessChannelDraft(channel, revisedDraft, {
Expand Down Expand Up @@ -288,7 +293,7 @@ async function reviseDuplicateDestination({
qualityIssues: [target.guidance],
}),
modelOverride,
config,
config: { ...config, requestKind: "duplicate_repair", destination: target.channel },
});
const revisedDraft = normalizeDestinationDraft(revisedRaw, target.channel, generationInputs);
const quality = assessChannelDraft(target.channel, revisedDraft, {
Expand Down Expand Up @@ -409,13 +414,28 @@ export async function generateStudioPackage(inputs) {
}

const modelOverride = model_name || config?.modelName || providerMeta.defaultModel;
let requestBudget = config.requestBudget || null;
let requestBudgetPlan = null;

try {
const hardRequestLimit = requestBudget?.maxRequests ?? config.maxProviderRequests;
requestBudgetPlan = estimateGenerationRequestBudget(channels.length, { maxRequests: hardRequestLimit });
if (!requestBudgetPlan.withinBudget) {
throw generationRequestBudgetError({
plannedMaxRequests: requestBudgetPlan.plannedMaxRequests,
maxRequests: requestBudgetPlan.hardMaxRequests,
});
}
requestBudget = requestBudget || createGenerationExecutionBudget({
maxRequests: requestBudgetPlan.hardMaxRequests,
});
const executionConfig = { ...config, requestBudget };

const rawBrief = await generateJSON({
provider: generator,
prompt: campaignBriefPrompt,
modelOverride,
config,
config: { ...executionConfig, requestKind: "strategy" },
});

const pkg = normalizePackage(rawBrief, generationInputs, { allowTemplateFallback: false });
Expand All @@ -433,6 +453,10 @@ export async function generateStudioPackage(inputs) {
code: "strategy_quality_blocked",
providerUsed: generator,
fallbackUsed: false,
generation_execution: {
plan: requestBudgetPlan,
actual: requestBudget.snapshot(),
},
strategy_review: {
status: strategyQuality.status,
issues: strategyQuality.issues,
Expand All @@ -459,10 +483,10 @@ export async function generateStudioPackage(inputs) {
generationInputs,
provider: generator,
modelOverride,
config,
}), config.destinationConcurrency, { signal: config.signal });
config: executionConfig,
}), executionConfig.destinationConcurrency, { signal: executionConfig.signal });

if (config.signal?.aborted) {
if (executionConfig.signal?.aborted) {
const cancelled = new Error("Generation request was cancelled.");
cancelled.code = "provider_request_cancelled";
cancelled.status = 499;
Expand All @@ -489,9 +513,9 @@ export async function generateStudioPackage(inputs) {
generationInputs,
provider: generator,
modelOverride,
config,
}), config.destinationConcurrency, { signal: config.signal });
if (config.signal?.aborted) {
config: executionConfig,
}), executionConfig.destinationConcurrency, { signal: executionConfig.signal });
if (executionConfig.signal?.aborted) {
const cancelled = new Error("Generation request was cancelled.");
cancelled.code = "provider_request_cancelled";
cancelled.status = 499;
Expand Down Expand Up @@ -547,6 +571,11 @@ export async function generateStudioPackage(inputs) {
throw new Error(`Every selected destination failed: ${failedDestinations.map((item) => item.channel).join(", ")}.`);
}

const generationExecution = {
plan: requestBudgetPlan,
actual: requestBudget.snapshot(),
};

pkg.generation = {
mode: "staged_agent",
provider: generator,
Expand All @@ -555,6 +584,7 @@ export async function generateStudioPackage(inputs) {
strategyQuality,
duplicateRevisionTargets: duplicateTargets,
unresolvedDuplicateRevisionTargets: unresolvedDuplicateTargets,
execution: generationExecution,
destinations: generationStatus,
};

Expand All @@ -570,6 +600,7 @@ export async function generateStudioPackage(inputs) {
providerUsed: generator,
fallbackUsed: false,
partialFailureUsed,
generation_execution: generationExecution,
generation_status: generationStatus,
chatbot_prompt: campaignBriefPrompt,
warnings: Array.from(new Set([...contextWarnings, ...generationWarnings])),
Expand Down
33 changes: 23 additions & 10 deletions frontend/lib/ai/generateText.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,34 +20,47 @@ export async function generateText({ provider, prompt, modelOverride = null, con
...config,
maxTokens: resolveOutputTokenBudget(prompt, config.maxTokens),
};
let budgetTicket = null;

try {
budgetTicket = config.requestBudget?.begin?.({
provider: p,
model: modelOverride || resolvedConfig.modelName || "",
kind: config.requestKind || "provider_request",
destination: config.destination || "",
maxOutputTokens: resolvedConfig.maxTokens,
}) || null;
let result;
switch (p) {
case "vercel_gateway":
return await generateVercelGateway(prompt, modelOverride, resolvedConfig);
result = await generateVercelGateway(prompt, modelOverride, resolvedConfig); break;
case "openai":
return await generateOpenAI(prompt, modelOverride, resolvedConfig);
result = await generateOpenAI(prompt, modelOverride, resolvedConfig); break;
case "claude":
return await generateClaude(prompt, modelOverride, resolvedConfig);
result = await generateClaude(prompt, modelOverride, resolvedConfig); break;
case "gemini":
return await generateGemini(prompt, modelOverride, resolvedConfig);
result = await generateGemini(prompt, modelOverride, resolvedConfig); break;
case "groq":
return await generateGroq(prompt, modelOverride, resolvedConfig);
result = await generateGroq(prompt, modelOverride, resolvedConfig); break;
case "openrouter":
return await generateOpenRouter(prompt, modelOverride, resolvedConfig);
result = await generateOpenRouter(prompt, modelOverride, resolvedConfig); break;
case "ollama":
return await generateOllama(prompt, modelOverride, resolvedConfig);
result = await generateOllama(prompt, modelOverride, resolvedConfig); break;
case "lmstudio":
return await generateLMStudio(prompt, modelOverride, resolvedConfig);
result = await generateLMStudio(prompt, modelOverride, resolvedConfig); break;
case "custom":
return await generateCustomOpenAI(prompt, modelOverride, resolvedConfig);
result = await generateCustomOpenAI(prompt, modelOverride, resolvedConfig); break;
default:
throw new Error(`Text generation not supported for provider mode: "${provider}"`);
}
config.requestBudget?.finish?.(budgetTicket, { ok: true });
return result;
} catch (error) {
throw normalizeProviderError(error, {
const normalized = normalizeProviderError(error, {
provider: p,
model: modelOverride || "",
});
config.requestBudget?.finish?.(budgetTicket, { ok: false, errorCode: normalized.code });
throw normalized;
}
}
116 changes: 116 additions & 0 deletions frontend/lib/ai/generationExecutionBudget.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
export const DEFAULT_MAX_PROVIDER_REQUESTS = 40;
export const MAX_PROVIDER_REQUESTS = 40;

function integer(value, fallback) {
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : fallback;
}

export function resolveProviderRequestLimit(value) {
return Math.max(1, Math.min(MAX_PROVIDER_REQUESTS, integer(value, DEFAULT_MAX_PROVIDER_REQUESTS)));
}

export function estimateGenerationRequestBudget(destinationCount, { maxRequests = DEFAULT_MAX_PROVIDER_REQUESTS } = {}) {
const destinations = Math.max(0, integer(destinationCount, 0));
const plannedMaxRequests = 1 + (destinations * 3);
const hardMaxRequests = resolveProviderRequestLimit(maxRequests);
return Object.freeze({
destinations,
plannedMaxRequests,
hardMaxRequests,
withinBudget: plannedMaxRequests <= hardMaxRequests,
});
}

export function generationRequestBudgetError({ plannedMaxRequests = null, maxRequests = DEFAULT_MAX_PROVIDER_REQUESTS } = {}) {
const error = new Error(
plannedMaxRequests
? `This generation plan may require up to ${plannedMaxRequests} provider requests, above the hard limit of ${maxRequests}. Reduce destinations before generating.`
: `Generation reached the hard provider-request limit of ${maxRequests}. Reduce destinations or retry only the affected destination.`,
);
error.code = "generation_request_budget_exceeded";
error.status = 422;
return error;
}

export function createGenerationExecutionBudget({
maxRequests = DEFAULT_MAX_PROVIDER_REQUESTS,
now = () => Date.now(),
} = {}) {
const hardMaxRequests = resolveProviderRequestLimit(maxRequests);
const startedAt = now();
let sequence = 0;
let completed = 0;
let failed = 0;
const entries = [];

function begin({ provider = "", model = "", kind = "provider_request", destination = "", maxOutputTokens = 0 } = {}) {
if (entries.length >= hardMaxRequests) {
throw generationRequestBudgetError({ maxRequests: hardMaxRequests });
}
const ticket = {
id: ++sequence,
provider: String(provider || ""),
model: String(model || ""),
kind: String(kind || "provider_request"),
destination: String(destination || ""),
maxOutputTokens: Math.max(0, Number(maxOutputTokens) || 0),
startedAt: now(),
finishedAt: null,
durationMs: null,
status: "running",
errorCode: "",
};
entries.push(ticket);
return ticket;
}

function finish(ticket, { ok = true, errorCode = "" } = {}) {
if (!ticket || ticket.finishedAt !== null) return;
ticket.finishedAt = now();
ticket.durationMs = Math.max(0, ticket.finishedAt - ticket.startedAt);
ticket.status = ok ? "completed" : "failed";
ticket.errorCode = ok ? "" : String(errorCode || "provider_request_failed");
if (ok) completed += 1;
else failed += 1;
}

function snapshot() {
const finishedAt = now();
const byKind = {};
let maxOutputTokens = 0;
for (const entry of entries) {
byKind[entry.kind] = (byKind[entry.kind] || 0) + 1;
maxOutputTokens += entry.maxOutputTokens;
}
const retryRequests = (byKind.destination_revision || 0) + (byKind.duplicate_repair || 0);
return Object.freeze({
requestCount: entries.length,
maxRequests: hardMaxRequests,
retryRequests,
completedRequests: completed,
failedRequests: failed,
durationMs: Math.max(0, finishedAt - startedAt),
maxOutputTokens,
byKind: Object.freeze({ ...byKind }),
requests: Object.freeze(entries.map((entry) => Object.freeze({
id: entry.id,
provider: entry.provider,
model: entry.model,
kind: entry.kind,
destination: entry.destination,
maxOutputTokens: entry.maxOutputTokens,
durationMs: entry.durationMs,
status: entry.status,
errorCode: entry.errorCode,
}))),
});
}

return Object.freeze({
maxRequests: hardMaxRequests,
begin,
finish,
snapshot,
});
}
5 changes: 5 additions & 0 deletions frontend/lib/ai/providerErrors.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const SAFE_CODES = Object.freeze({
PAYMENT_REQUIRED: "provider_payment_required",
TIMEOUT: "provider_timeout",
CANCELLED: "provider_request_cancelled",
REQUEST_BUDGET_EXCEEDED: "generation_request_budget_exceeded",
UNAVAILABLE: "provider_unavailable",
MALFORMED_RESPONSE: "provider_malformed_response",
EMPTY_RESPONSE: "provider_empty_response",
Expand Down Expand Up @@ -39,6 +40,9 @@ function classify(error) {
if (error?.code === "provider_request_cancelled") {
return { code: SAFE_CODES.CANCELLED, retryable: true, action: "retry_destination" };
}
if (error?.code === "generation_request_budget_exceeded") {
return { code: SAFE_CODES.REQUEST_BUDGET_EXCEEDED, retryable: false, action: "reduce_destinations" };
}
if (error?.name === "AbortError" || /timed?\s*out|timeout/.test(message)) {
return { code: SAFE_CODES.TIMEOUT, retryable: true, action: "retry_destination" };
}
Expand Down Expand Up @@ -86,6 +90,7 @@ function safeMessage(code, provider) {
case SAFE_CODES.PAYMENT_REQUIRED: return `${label} requires billing or credits before generation can continue.`;
case SAFE_CODES.TIMEOUT: return `${label} did not respond within the request limit.`;
case SAFE_CODES.CANCELLED: return "Generation was cancelled before the provider request completed.";
case SAFE_CODES.REQUEST_BUDGET_EXCEEDED: return "Generation reached the configured provider-request budget. Reduce destinations or retry only the affected destination.";
case SAFE_CODES.UNAVAILABLE: return `${label} is temporarily unavailable.`;
case SAFE_CODES.MALFORMED_RESPONSE: return `${label} returned a response that could not be validated as the required JSON contract.`;
case SAFE_CODES.EMPTY_RESPONSE: return `${label} returned no usable model output.`;
Expand Down
Loading
Loading