feat(canvas): finalize mixer reconnect swap and related updates

This commit is contained in:
2026-04-11 07:42:42 +02:00
parent f3dcaf89f2
commit 028fce35c2
52 changed files with 3859 additions and 272 deletions

View File

@@ -58,6 +58,7 @@ Alle Node-Typen werden über Validators definiert: `phase1NodeTypeValidator`, `n
| `video-prompt` | `content`, `modelId`, `durationSeconds` | KI-Video-Steuer-Node (Eingabe) |
| `ai-video` | `storageId`, `prompt`, `model`, `modelLabel`, `durationSeconds`, `creditCost`, `generatedAt`, `taskId` (transient) | Generiertes KI-Video (System-Output) |
| `compare` | `leftNodeId`, `rightNodeId`, `sliderPosition` | Vergleichs-Node |
| `mixer` | `blendMode`, `opacity`, `offsetX`, `offsetY` | V1 Merge-Control-Node mit pseudo-image Output (kein Storage-Write) |
| `frame` | `label`, `exportWidth`, `exportHeight`, `backgroundColor` | Artboard |
| `group` | `label`, `collapsed` | Container-Node |
| `note` | `content`, `color` | Anmerkung |
@@ -327,8 +328,17 @@ Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genu
- Ziel: `ai-image`, `ai-video`, `compare` → Target-Ports
- `video-prompt``ai-video` ✅ (einzige gültige Kombination für Video-Flow)
- `ai-video` als Source für andere Nodes → ❌ (nur Compare)
- `mixer` akzeptiert nur `image|asset|ai-image|render` als Source-Typ
- `mixer` akzeptiert nur Target-Handles `base` und `overlay`
- `mixer` erlaubt max. eine eingehende Kante pro Handle und max. zwei insgesamt
- Curves- und Adjustment-Node-Presets: Nur Presets nutzen, keine direkten Edges
### Mixer V1: Backend-Scope
- `mixer` ist ein Control-Node mit pseudo-image Semantik, nicht mit persistiertem Medien-Output.
- Keine zusaetzlichen Convex-Tabellen oder Storage-Flows fuer Mixer-Vorschauen.
- Validierung laeuft client- und serverseitig ueber dieselbe Policy (`validateCanvasConnectionPolicy`); `edges.ts` delegiert darauf fuer Paritaet.
---
## Storage (`storage.ts`)

View File

@@ -11,7 +11,13 @@ import { api, internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { generateStructuredObjectViaOpenRouter } from "./openrouter";
import { getNodeDataRecord } from "./ai_node_data";
import { formatTerminalStatusMessage } from "./ai_errors";
import {
errorMessage,
formatTerminalStatusMessage,
getErrorCode,
getErrorSource,
getProviderStatus,
} from "./ai_errors";
import {
areClarificationAnswersComplete,
buildPreflightClarificationQuestions,
@@ -119,14 +125,17 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
},
};
const metadataValueSchema: Record<string, unknown> = {
anyOf: [
{ type: "string" },
{
const metadataEntrySchema: Record<string, unknown> = {
type: "object",
additionalProperties: false,
required: ["key", "values"],
properties: {
key: { type: "string" },
values: {
type: "array",
items: { type: "string" },
},
],
},
};
const stepOutputProperties: Record<string, unknown> = {};
@@ -134,31 +143,31 @@ function buildExecuteSchema(stepIds: string[]): Record<string, unknown> {
stepOutputProperties[stepId] = {
type: "object",
additionalProperties: false,
required: [
"title",
"channel",
"artifactType",
"previewText",
"sections",
"metadata",
"qualityChecks",
],
properties: {
title: { type: "string" },
channel: { type: "string" },
required: [
"title",
"channel",
"artifactType",
"previewText",
"sections",
"metadataEntries",
"qualityChecks",
],
properties: {
title: { type: "string" },
channel: { type: "string" },
artifactType: { type: "string" },
previewText: { type: "string" },
sections: {
type: "array",
items: sectionSchema,
},
metadata: {
type: "object",
additionalProperties: metadataValueSchema,
},
qualityChecks: {
type: "array",
items: { type: "string" },
sections: {
type: "array",
items: sectionSchema,
},
metadataEntries: {
type: "array",
items: metadataEntrySchema,
},
qualityChecks: {
type: "array",
items: { type: "string" },
},
},
};
@@ -297,6 +306,7 @@ type InternalApiShape = {
previewText: string;
sections: AgentOutputSection[];
metadata: Record<string, string | string[]>;
metadataLabels: Record<string, string>;
body: string;
},
unknown
@@ -351,6 +361,18 @@ function trimText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function logAgentFailure(stage: string, context: Record<string, unknown>, error: unknown): void {
const formattedStatus = formatTerminalStatusMessage(error);
console.error(`[agents][${stage}] failed`, {
...context,
statusMessage: formattedStatus,
code: getErrorCode(error),
source: getErrorSource(error),
providerStatus: getProviderStatus(error),
message: errorMessage(error),
});
}
function normalizeAnswerMap(raw: unknown): AgentClarificationAnswerMap {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
@@ -519,6 +541,7 @@ function buildSkeletonOutputData(input: {
previewText: buildSkeletonPreviewPlaceholder(input.step.title),
sections: [],
metadata: {},
metadataLabels: {},
body: "",
...(definitionVersion ? { definitionVersion } : {}),
};
@@ -535,6 +558,7 @@ function buildCompletedOutputData(input: {
previewText: string;
sections: AgentOutputSection[];
metadata: Record<string, string | string[]>;
metadataLabels: Record<string, string>;
qualityChecks: string[];
body: string;
};
@@ -563,6 +587,10 @@ function buildCompletedOutputData(input: {
sections: normalizedSections,
metadata:
input.output.metadata && typeof input.output.metadata === "object" ? input.output.metadata : {},
metadataLabels:
input.output.metadataLabels && typeof input.output.metadataLabels === "object"
? input.output.metadataLabels
: {},
body: deriveLegacyBodyFallback({
title: trimText(input.output.title) || trimText(input.step.title),
previewText: normalizedPreviewText,
@@ -976,6 +1004,7 @@ export const completeExecutionStepOutput = internalMutation({
}),
),
metadata: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
metadataLabels: v.record(v.string(), v.string()),
body: v.string(),
},
handler: async (ctx, args) => {
@@ -1018,6 +1047,7 @@ export const completeExecutionStepOutput = internalMutation({
previewText: args.previewText,
sections: args.sections,
metadata: args.metadata,
metadataLabels: args.metadataLabels,
qualityChecks: args.qualityChecks,
body: args.body,
},
@@ -1254,6 +1284,7 @@ export const analyzeAgent = internalAction({
shouldDecrementConcurrency: args.shouldDecrementConcurrency,
});
} catch (error) {
logAgentFailure("analyzeAgent", { nodeId: args.nodeId, modelId: args.modelId }, error);
await releaseInternalReservationBestEffort(ctx, args.reservationId);
await ctx.runMutation(internalApi.agents.setAgentError, {
nodeId: args.nodeId,
@@ -1306,6 +1337,17 @@ export const executeAgent = internalAction({
const executeSchema = buildExecuteSchema(executionSteps.map((step) => step.id));
console.info("[agents][executeAgent] request context", {
nodeId: args.nodeId,
modelId: args.modelId,
stepCount: executionSteps.length,
stepIds: executionSteps.map((step) => step.id),
artifactTypes: executionSteps.map((step) => step.artifactType),
channels: executionSteps.map((step) => step.channel),
incomingContextLength: incomingContext.length,
executionPlanSummaryLength: executionPlanSummary.length,
});
const execution = await generateStructuredObjectViaOpenRouter<{
summary: string;
stepOutputs: Record<string, AgentStructuredOutputDraft>;
@@ -1375,6 +1417,7 @@ export const executeAgent = internalAction({
previewText: normalized.previewText,
sections: normalized.sections,
metadata: normalized.metadata,
metadataLabels: normalized.metadataLabels,
body: normalized.body,
});
}
@@ -1393,6 +1436,7 @@ export const executeAgent = internalAction({
await decrementConcurrencyIfNeeded(ctx, args.shouldDecrementConcurrency, args.userId);
} catch (error) {
logAgentFailure("executeAgent", { nodeId: args.nodeId, modelId: args.modelId }, error);
await releaseInternalReservationBestEffort(ctx, args.reservationId);
await ctx.runMutation(internalApi.agents.setAgentError, {
nodeId: args.nodeId,
@@ -1404,6 +1448,7 @@ export const executeAgent = internalAction({
});
export const __testables = {
buildExecuteSchema,
buildSkeletonOutputData,
buildCompletedOutputData,
getAnalyzeExecutionStepRequiredFields,
@@ -1485,6 +1530,7 @@ export const runAgent = action({
scheduled = true;
return { queued: true, nodeId: args.nodeId };
} catch (error) {
logAgentFailure("runAgent", { nodeId: args.nodeId, modelId: selectedModel.id }, error);
await releasePublicReservationBestEffort(ctx, reservationId);
await ctx.runMutation(internalApi.agents.setAgentError, {
nodeId: args.nodeId,
@@ -1572,6 +1618,7 @@ export const resumeAgent = action({
return { queued: true, nodeId: args.nodeId };
} catch (error) {
logAgentFailure("resumeAgent", { nodeId: args.nodeId, modelId }, error);
await releasePublicReservationBestEffort(ctx, reservationId ?? null);
await ctx.runMutation(internalApi.agents.setAgentError, {
nodeId: args.nodeId,

View File

@@ -14,6 +14,47 @@ interface ErrorData {
[key: string]: unknown;
}
function trimText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function parseStructuredProviderErrorMessage(raw: string): {
message: string;
code: string;
type: string;
} | null {
const trimmed = raw.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
const errorBlock =
record.error && typeof record.error === "object" && !Array.isArray(record.error)
? (record.error as Record<string, unknown>)
: undefined;
const message =
trimText(errorBlock?.message) || trimText(record.message) || trimText(errorBlock?.detail);
const code = trimText(errorBlock?.code) || trimText(record.code);
const type = trimText(errorBlock?.type) || trimText(record.type);
if (!message) {
return null;
}
return { message, code, type };
} catch {
return null;
}
}
export function getErrorCode(error: unknown): string | undefined {
if (error instanceof ConvexError) {
const data = error.data as ErrorData;
@@ -166,13 +207,34 @@ export function formatTerminalStatusMessage(error: unknown): string {
typeof convexData?.status === "number" && Number.isFinite(convexData.status)
? convexData.status
: null;
const structuredProviderFromMessage = parseStructuredProviderErrorMessage(convexDataMessage);
const structuredProviderMessageFromData =
trimText(convexData?.providerMessage) ||
structuredProviderFromMessage?.message;
const structuredProviderCodeFromData =
trimText(convexData?.providerCode) || structuredProviderFromMessage?.code;
const structuredProviderTypeFromData =
trimText(convexData?.providerType) || structuredProviderFromMessage?.type;
const structuredProviderDecorators = [
structuredProviderCodeFromData ? `code=${structuredProviderCodeFromData}` : "",
structuredProviderTypeFromData ? `type=${structuredProviderTypeFromData}` : "",
].filter(Boolean);
const structuredProviderSuffix =
structuredProviderDecorators.length > 0
? ` [${structuredProviderDecorators.join(", ")}]`
: "";
const message =
code === "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR"
? convexDataMessage ||
(convexDataStatus !== null
? `HTTP ${convexDataStatus}`
: "Anfrage fehlgeschlagen")
? structuredProviderMessageFromData
? `${convexDataStatus !== null ? `OpenRouter ${convexDataStatus}: ` : ""}${structuredProviderMessageFromData}${structuredProviderSuffix}`
: convexDataMessage ||
(convexDataStatus !== null
? `HTTP ${convexDataStatus}`
: "Anfrage fehlgeschlagen")
: errorMessage(error).trim() || "Generation failed";
const { category } =

View File

@@ -8,37 +8,41 @@ import {
} from "../lib/canvas-connection-policy";
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
const MIXER_HANDLES = new Set(["base", "overlay"] as const);
async function countIncomingEdges(
function normalizeMixerHandle(handle: string | undefined): "base" | "overlay" | null {
if (handle == null || handle === "" || handle === "null") {
return "base";
}
if (MIXER_HANDLES.has(handle as "base" | "overlay")) {
return handle as "base" | "overlay";
}
return null;
}
async function getIncomingEdgePolicyContext(
ctx: MutationCtx,
args: {
targetNodeId: Id<"nodes">;
edgeIdToIgnore?: Id<"edges">;
},
): Promise<number> {
): Promise<{ count: number; targetHandles: Array<string | undefined> }> {
const incomingEdgesQuery = ctx.db
.query("edges")
.withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId));
const checkStartedAt = Date.now();
const incomingEdges = await (
args.edgeIdToIgnore
? incomingEdgesQuery.take(2)
: incomingEdgesQuery.first()
);
const incomingEdges = await incomingEdgesQuery.take(3);
const checkDurationMs = Date.now() - checkStartedAt;
const incomingCount = Array.isArray(incomingEdges)
? incomingEdges.filter((edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore).length
: incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore
? 1
: 0;
const filteredIncomingEdges = incomingEdges.filter(
(edge: Doc<"edges">) => edge._id !== args.edgeIdToIgnore,
);
const incomingCount = filteredIncomingEdges.length;
if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
const inspected = Array.isArray(incomingEdges)
? incomingEdges.length
: incomingEdges === null
? 0
: 1;
const inspected = incomingEdges.length;
console.warn("[edges.assertTargetAllowsIncomingEdge] slow incoming edge check", {
targetNodeId: args.targetNodeId,
@@ -48,7 +52,10 @@ async function countIncomingEdges(
});
}
return incomingCount;
return {
count: incomingCount,
targetHandles: filteredIncomingEdges.map((edge) => edge.targetHandle),
};
}
async function assertConnectionPolicy(
@@ -56,6 +63,7 @@ async function assertConnectionPolicy(
args: {
sourceNodeId: Id<"nodes">;
targetNodeId: Id<"nodes">;
targetHandle?: string;
edgeIdToIgnore?: Id<"edges">;
},
): Promise<void> {
@@ -65,7 +73,7 @@ async function assertConnectionPolicy(
throw new Error("Source or target node not found");
}
const targetIncomingCount = await countIncomingEdges(ctx, {
const targetIncoming = await getIncomingEdgePolicyContext(ctx, {
targetNodeId: args.targetNodeId,
edgeIdToIgnore: args.edgeIdToIgnore,
});
@@ -73,7 +81,9 @@ async function assertConnectionPolicy(
const reason = validateCanvasConnectionPolicy({
sourceType: sourceNode.type,
targetType: targetNode.type,
targetIncomingCount,
targetIncomingCount: targetIncoming.count,
targetHandle: args.targetHandle,
targetIncomingHandles: targetIncoming.targetHandles,
});
if (reason) {
@@ -83,7 +93,7 @@ async function assertConnectionPolicy(
edgeIdToIgnore: args.edgeIdToIgnore,
sourceType: sourceNode.type,
targetType: targetNode.type,
targetIncomingCount,
targetIncomingCount: targetIncoming.count,
reason,
});
throw new Error(getCanvasConnectionValidationMessage(reason));
@@ -151,6 +161,7 @@ export const create = mutation({
targetNodeId: v.id("nodes"),
sourceHandle: v.optional(v.string()),
targetHandle: v.optional(v.string()),
edgeIdToIgnore: v.optional(v.id("edges")),
clientRequestId: v.optional(v.string()),
},
handler: async (ctx, args) => {
@@ -207,9 +218,23 @@ export const create = mutation({
throw new Error("Cannot connect a node to itself");
}
const edgeToIgnore = args.edgeIdToIgnore
? await ctx.db.get(args.edgeIdToIgnore)
: null;
if (args.edgeIdToIgnore) {
if (!edgeToIgnore) {
throw new Error("Edge to ignore not found");
}
if (edgeToIgnore.canvasId !== args.canvasId) {
throw new Error("Edge to ignore must belong to the same canvas");
}
}
await assertConnectionPolicy(ctx, {
sourceNodeId: args.sourceNodeId,
targetNodeId: args.targetNodeId,
targetHandle: args.targetHandle,
edgeIdToIgnore: args.edgeIdToIgnore,
});
const edgeId = await ctx.db.insert("edges", {
@@ -220,6 +245,10 @@ export const create = mutation({
targetHandle: args.targetHandle,
});
if (edgeToIgnore) {
await ctx.db.delete(edgeToIgnore._id);
}
console.info("[canvas.updatedAt] touch", {
canvasId: args.canvasId,
source: "edges.create",
@@ -242,6 +271,54 @@ export const create = mutation({
},
});
export const swapMixerInputs = mutation({
args: {
canvasId: v.id("canvases"),
edgeId: v.id("edges"),
otherEdgeId: v.id("edges"),
},
handler: async (ctx, args) => {
const user = await requireAuth(ctx);
const canvas = await ctx.db.get(args.canvasId);
if (!canvas || canvas.ownerId !== user.userId) {
throw new Error("Canvas not found");
}
if (args.edgeId === args.otherEdgeId) {
throw new Error("Edge IDs must be different");
}
const edge = await ctx.db.get(args.edgeId);
const otherEdge = await ctx.db.get(args.otherEdgeId);
if (!edge || !otherEdge) {
throw new Error("Edge not found");
}
if (edge.canvasId !== args.canvasId || otherEdge.canvasId !== args.canvasId) {
throw new Error("Edges must belong to the same canvas");
}
if (edge.targetNodeId !== otherEdge.targetNodeId) {
throw new Error("Edges must target the same mixer node");
}
const mixerNode = await ctx.db.get(edge.targetNodeId);
if (!mixerNode || mixerNode.canvasId !== args.canvasId || mixerNode.type !== "mixer") {
throw new Error("Mixer node not found");
}
const edgeHandle = normalizeMixerHandle(edge.targetHandle);
const otherEdgeHandle = normalizeMixerHandle(otherEdge.targetHandle);
if (!edgeHandle || !otherEdgeHandle || edgeHandle === otherEdgeHandle) {
throw new Error("Mixer swap requires one base and one overlay edge");
}
await ctx.db.patch(edge._id, { targetHandle: otherEdgeHandle });
await ctx.db.patch(otherEdge._id, { targetHandle: edgeHandle });
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
},
});
/**
* Edge löschen.
*/

View File

@@ -418,34 +418,27 @@ function normalizeNodeDataForWrite(
return preserveNodeFavorite(data, data);
}
async function countIncomingEdges(
async function getIncomingEdgePolicyContext(
ctx: MutationCtx,
args: {
targetNodeId: Id<"nodes">;
edgeIdToIgnore?: Id<"edges">;
},
): Promise<number> {
): Promise<{ count: number; targetHandles: Array<string | undefined> }> {
const incomingEdgesQuery = ctx.db
.query("edges")
.withIndex("by_target", (q) => q.eq("targetNodeId", args.targetNodeId));
const checkStartedAt = Date.now();
const incomingEdges = await (
args.edgeIdToIgnore ? incomingEdgesQuery.take(2) : incomingEdgesQuery.first()
);
const incomingEdges = await incomingEdgesQuery.take(3);
const checkDurationMs = Date.now() - checkStartedAt;
const incomingCount = Array.isArray(incomingEdges)
? incomingEdges.filter((edge) => edge._id !== args.edgeIdToIgnore).length
: incomingEdges !== null && incomingEdges._id !== args.edgeIdToIgnore
? 1
: 0;
const filteredIncomingEdges = incomingEdges.filter(
(edge) => edge._id !== args.edgeIdToIgnore,
);
const incomingCount = filteredIncomingEdges.length;
if (checkDurationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
const inspected = Array.isArray(incomingEdges)
? incomingEdges.length
: incomingEdges === null
? 0
: 1;
const inspected = incomingEdges.length;
console.warn("[nodes.countIncomingEdges] slow incoming edge check", {
targetNodeId: args.targetNodeId,
@@ -455,7 +448,10 @@ async function countIncomingEdges(
});
}
return incomingCount;
return {
count: incomingCount,
targetHandles: filteredIncomingEdges.map((edge) => edge.targetHandle),
};
}
async function assertConnectionPolicyForTypes(
@@ -464,16 +460,21 @@ async function assertConnectionPolicyForTypes(
sourceType: Doc<"nodes">["type"];
targetType: Doc<"nodes">["type"];
targetNodeId: Id<"nodes">;
targetHandle?: string;
edgeIdToIgnore?: Id<"edges">;
},
): Promise<void> {
const targetIncoming = await getIncomingEdgePolicyContext(ctx, {
targetNodeId: args.targetNodeId,
edgeIdToIgnore: args.edgeIdToIgnore,
});
const reason = validateCanvasConnectionPolicy({
sourceType: args.sourceType,
targetType: args.targetType,
targetIncomingCount: await countIncomingEdges(ctx, {
targetNodeId: args.targetNodeId,
edgeIdToIgnore: args.edgeIdToIgnore,
}),
targetIncomingCount: targetIncoming.count,
targetHandle: args.targetHandle,
targetIncomingHandles: targetIncoming.targetHandles,
});
if (reason) {
@@ -870,6 +871,8 @@ export const createWithEdgeSplit = mutation({
sourceType: sourceNode.type,
targetType: args.type,
targetIncomingCount: 0,
targetHandle: args.newNodeTargetHandle,
targetIncomingHandles: [],
});
if (firstEdgeReason) {
throw new Error(getCanvasConnectionValidationMessage(firstEdgeReason));
@@ -879,6 +882,7 @@ export const createWithEdgeSplit = mutation({
sourceType: args.type,
targetType: targetNode.type,
targetNodeId: edge.targetNodeId,
targetHandle: args.splitTargetHandle,
edgeIdToIgnore: args.splitEdgeId,
});
@@ -1008,6 +1012,7 @@ export const splitEdgeAtExistingNode = mutation({
sourceType: sourceNode.type,
targetType: middle.type,
targetNodeId: args.middleNodeId,
targetHandle: args.newNodeTargetHandle,
});
await ctx.db.insert("edges", {
@@ -1022,6 +1027,7 @@ export const splitEdgeAtExistingNode = mutation({
sourceType: middle.type,
targetType: targetNode.type,
targetNodeId: edge.targetNodeId,
targetHandle: args.splitTargetHandle,
edgeIdToIgnore: args.splitEdgeId,
});
@@ -1098,6 +1104,8 @@ export const createWithEdgeFromSource = mutation({
sourceType: source.type,
targetType: args.type,
targetIncomingCount: 0,
targetHandle: args.targetHandle,
targetIncomingHandles: [],
});
if (fromSourceReason) {
throw new Error(getCanvasConnectionValidationMessage(fromSourceReason));
@@ -1188,6 +1196,7 @@ export const createWithEdgeToTarget = mutation({
sourceType: args.type,
targetType: target.type,
targetNodeId,
targetHandle: args.targetHandle,
});
const normalizedData = normalizeNodeDataForWrite(args.type, args.data);

View File

@@ -151,6 +151,161 @@ function parseStructuredJsonFromMessageContent(contentText: string):
return { ok: false };
}
type StructuredOpenRouterErrorInfo = {
userMessage: string;
providerMessage: string;
providerCode: string;
providerType: string;
rawBodyPreview: string;
};
type StructuredSchemaDiagnostics = {
topLevelType: string;
topLevelRequiredCount: number;
topLevelPropertyCount: number;
schemaBytes: number;
messageCount: number;
messageLengths: number[];
hasAnyOf: boolean;
hasOneOf: boolean;
hasAllOf: boolean;
hasPatternProperties: boolean;
hasDynamicAdditionalProperties: boolean;
};
function walkStructuredSchema(
value: unknown,
visitor: (node: Record<string, unknown>) => void,
): void {
if (!value || typeof value !== "object") {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
walkStructuredSchema(item, visitor);
}
return;
}
const record = value as Record<string, unknown>;
visitor(record);
for (const nested of Object.values(record)) {
walkStructuredSchema(nested, visitor);
}
}
function getStructuredSchemaDiagnostics(args: {
schema: Record<string, unknown>;
messages: Array<{
role: "system" | "user" | "assistant";
content: string;
}>;
}): StructuredSchemaDiagnostics {
const topLevelType = typeof args.schema.type === "string" ? args.schema.type : "unknown";
const topLevelRequiredCount = Array.isArray(args.schema.required) ? args.schema.required.length : 0;
const properties =
args.schema.properties && typeof args.schema.properties === "object" && !Array.isArray(args.schema.properties)
? (args.schema.properties as Record<string, unknown>)
: null;
const diagnostics: StructuredSchemaDiagnostics = {
topLevelType,
topLevelRequiredCount,
topLevelPropertyCount: properties ? Object.keys(properties).length : 0,
schemaBytes: JSON.stringify(args.schema).length,
messageCount: args.messages.length,
messageLengths: args.messages.map((message) => message.content.length),
hasAnyOf: false,
hasOneOf: false,
hasAllOf: false,
hasPatternProperties: false,
hasDynamicAdditionalProperties: false,
};
walkStructuredSchema(args.schema, (node) => {
if (Array.isArray(node.anyOf) && node.anyOf.length > 0) {
diagnostics.hasAnyOf = true;
}
if (Array.isArray(node.oneOf) && node.oneOf.length > 0) {
diagnostics.hasOneOf = true;
}
if (Array.isArray(node.allOf) && node.allOf.length > 0) {
diagnostics.hasAllOf = true;
}
if (
node.patternProperties &&
typeof node.patternProperties === "object" &&
!Array.isArray(node.patternProperties)
) {
diagnostics.hasPatternProperties = true;
}
if (
node.additionalProperties &&
typeof node.additionalProperties === "object" &&
!Array.isArray(node.additionalProperties)
) {
diagnostics.hasDynamicAdditionalProperties = true;
}
});
return diagnostics;
}
function summarizeStructuredOpenRouterError(errorText: string, status: number): StructuredOpenRouterErrorInfo {
const trimmed = errorText.trim();
const rawBodyPreview = trimmed.slice(0, 4000);
let providerMessage = "";
let providerCode = "";
let providerType = "";
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const record = parsed as Record<string, unknown>;
const errorBlock =
record.error && typeof record.error === "object" && !Array.isArray(record.error)
? (record.error as Record<string, unknown>)
: undefined;
providerMessage =
(typeof errorBlock?.message === "string" ? errorBlock.message.trim() : "") ||
(typeof record.message === "string" ? record.message.trim() : "");
providerCode =
(typeof errorBlock?.code === "string" ? errorBlock.code.trim() : "") ||
(typeof record.code === "string" ? record.code.trim() : "");
providerType =
(typeof errorBlock?.type === "string" ? errorBlock.type.trim() : "") ||
(typeof record.type === "string" ? record.type.trim() : "");
}
} catch {
// Keep defaults and fall back to raw text below.
}
}
const decorators = [
providerCode ? `code=${providerCode}` : "",
providerType ? `type=${providerType}` : "",
].filter(Boolean);
const suffix = decorators.length > 0 ? ` [${decorators.join(", ")}]` : "";
const fallbackMessage = rawBodyPreview || `HTTP ${status}`;
const userMessage = providerMessage
? `OpenRouter ${status}: ${providerMessage}${suffix}`
: fallbackMessage;
return {
userMessage,
providerMessage,
providerCode,
providerType,
rawBodyPreview,
};
}
export async function generateStructuredObjectViaOpenRouter<T>(
apiKey: string,
args: {
@@ -163,6 +318,17 @@ export async function generateStructuredObjectViaOpenRouter<T>(
schema: Record<string, unknown>;
},
): Promise<T> {
const schemaDiagnostics = getStructuredSchemaDiagnostics({
schema: args.schema,
messages: args.messages,
});
console.info("[openrouter][structured] request", {
model: args.model,
schemaName: args.schemaName,
...schemaDiagnostics,
});
const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
method: "POST",
headers: {
@@ -188,10 +354,25 @@ export async function generateStructuredObjectViaOpenRouter<T>(
if (!response.ok) {
const errorText = await response.text();
const errorInfo = summarizeStructuredOpenRouterError(errorText, response.status);
console.error("[openrouter][structured] non-ok response", {
model: args.model,
schemaName: args.schemaName,
status: response.status,
providerMessage: errorInfo.providerMessage || undefined,
providerCode: errorInfo.providerCode || undefined,
providerType: errorInfo.providerType || undefined,
rawBodyPreview: errorInfo.rawBodyPreview,
});
throw new ConvexError({
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
status: response.status,
message: errorText,
message: errorInfo.userMessage,
providerMessage: errorInfo.providerMessage || undefined,
providerCode: errorInfo.providerCode || undefined,
providerType: errorInfo.providerType || undefined,
rawBodyPreview: errorInfo.rawBodyPreview,
});
}
@@ -223,6 +404,11 @@ export async function generateStructuredObjectViaOpenRouter<T>(
return parsedContent.value as T;
}
export const __testables = {
getStructuredSchemaDiagnostics,
summarizeStructuredOpenRouterError,
};
export interface OpenRouterModel {
id: string;
name: string;