fix(prompts): Prompt Studio fails to load prompts saved via UI

When prompts are saved via the Prompt Studio UI or PUT /prompts/{id} API
using prompt_data, the dotprompt_content field is null. The Prompt Studio
load path previously only read from dotprompt_content, causing the editor
to show a blank "New prompt" instead of the saved content.

This fix adds a fallback to parse prompt_data.content and
prompt_data.metadata when dotprompt_content is not available.

Changes:
- Modified parseExistingPrompt to check for prompt_data as fallback
- Added parsePromptData helper function to handle both direct and nested
  prompt_data formats
- Updated error message to mention both data sources
- Added comprehensive tests for prompt_data parsing

Fixes #23935
This commit is contained in:
BillionClaw 2026-03-18 15:37:50 +08:00
parent cec3e9e7d4
commit 4930c6d542
2 changed files with 216 additions and 3 deletions

View file

@ -339,7 +339,7 @@ User: Hello`,
},
};
expect(() => parseExistingPrompt(apiResponse)).toThrow("No dotprompt_content found in API response");
expect(() => parseExistingPrompt(apiResponse)).toThrow("No dotprompt_content or prompt_data found in API response");
});
it("should throw error for invalid dotprompt format", () => {
@ -377,6 +377,143 @@ output:
{ role: "user", content: "Enter task specifics. Use {{template_variables}} for dynamic inputs" },
]);
});
// Tests for prompt_data fallback
it("should parse prompt_data with direct format", () => {
const apiResponse = {
prompt_spec: {
litellm_params: {
prompt_data: {
content: "User: Hello from prompt_data",
metadata: {
model: "gpt-4",
temperature: 0.8,
},
},
},
prompt_id: "test-prompt",
},
};
const result = parseExistingPrompt(apiResponse);
expect(result.name).toBe("test-prompt");
expect(result.model).toBe("gpt-4");
expect(result.config.temperature).toBe(0.8);
expect(result.messages).toEqual([{ role: "user", content: "Hello from prompt_data" }]);
});
it("should parse prompt_data with nested format", () => {
const apiResponse = {
prompt_spec: {
litellm_params: {
prompt_data: {
"my-prompt": {
content: "User: Hello from nested prompt_data",
metadata: {
model: "gpt-3.5-turbo",
max_tokens: 500,
},
},
},
},
prompt_id: "my-prompt",
},
};
const result = parseExistingPrompt(apiResponse);
expect(result.name).toBe("my-prompt");
expect(result.model).toBe("gpt-3.5-turbo");
expect(result.config.max_tokens).toBe(500);
expect(result.messages).toEqual([{ role: "user", content: "Hello from nested prompt_data" }]);
});
it("should prefer dotprompt_content over prompt_data", () => {
const apiResponse = {
prompt_spec: {
litellm_params: {
dotprompt_content: `---
model: gpt-4
input:
schema:
output:
format: text
---
User: Hello from dotprompt`,
prompt_data: {
content: "User: Hello from prompt_data",
metadata: { model: "gpt-3.5-turbo" },
},
},
prompt_id: "test-prompt",
},
};
const result = parseExistingPrompt(apiResponse);
// Should use dotprompt_content, not prompt_data
expect(result.model).toBe("gpt-4");
expect(result.messages).toEqual([{ role: "user", content: "Hello from dotprompt" }]);
});
it("should handle prompt_data with developer message", () => {
const apiResponse = {
prompt_spec: {
litellm_params: {
prompt_data: {
content: "Developer: You are a helpful bot\n\nUser: Hello",
metadata: { model: "gpt-4" },
},
},
prompt_id: "test-prompt",
},
};
const result = parseExistingPrompt(apiResponse);
expect(result.developerMessage).toBe("You are a helpful bot");
expect(result.messages).toEqual([{ role: "user", content: "Hello" }]);
});
it("should handle prompt_data with tools", () => {
const apiResponse = {
prompt_spec: {
litellm_params: {
prompt_data: {
content: "User: What's the weather?",
metadata: {
model: "gpt-4",
tools: [{ type: "function", function: { name: "get_weather", description: "Get weather info" } }],
},
},
},
prompt_id: "test-prompt",
},
};
const result = parseExistingPrompt(apiResponse);
expect(result.tools).toHaveLength(1);
expect(result.tools[0].name).toBe("get_weather");
expect(result.tools[0].description).toBe("Get weather info");
});
it("should use default values for empty prompt_data", () => {
const apiResponse = {
prompt_spec: {
litellm_params: {
prompt_data: {
content: "",
metadata: {},
},
},
prompt_id: "test-prompt",
},
};
const result = parseExistingPrompt(apiResponse);
expect(result.model).toBe("gpt-4o");
expect(result.messages).toEqual([
{ role: "user", content: "Enter task specifics. Use {{template_variables}} for dynamic inputs" },
]);
});
});
describe("getVersionNumber", () => {

View file

@ -216,10 +216,21 @@ export const parseExistingPrompt = (apiResponse: any): PromptType => {
// Extract dotprompt_content from litellm_params
const dotpromptContent = apiResponse?.prompt_spec?.litellm_params?.dotprompt_content || "";
if (!dotpromptContent) {
throw new Error("No dotprompt_content found in API response");
// If dotprompt_content is available, use it
if (dotpromptContent) {
return parseDotpromptContent(dotpromptContent, apiResponse);
}
// Fallback: try to construct from prompt_data
const promptData = apiResponse?.prompt_spec?.litellm_params?.prompt_data;
if (promptData) {
return parsePromptData(promptData, apiResponse);
}
throw new Error("No dotprompt_content or prompt_data found in API response");
};
const parseDotpromptContent = (dotpromptContent: string, apiResponse: any): PromptType => {
// Split into frontmatter and content
const parts = dotpromptContent.split("---");
if (parts.length < 3) {
@ -249,6 +260,71 @@ export const parseExistingPrompt = (apiResponse: any): PromptType => {
};
};
const parsePromptData = (promptData: any, apiResponse: any): PromptType => {
// Handle nested structure: { prompt_id: { content, metadata } } or { content, metadata }
let content: string;
let metadata: any;
if (promptData.content !== undefined) {
// Direct format: { content, metadata }
content = promptData.content || "";
metadata = promptData.metadata || {};
} else {
// Nested format: { prompt_id: { content, metadata } }
const keys = Object.keys(promptData);
if (keys.length > 0) {
const firstKey = keys[0];
const nestedData = promptData[firstKey];
content = nestedData?.content || "";
metadata = nestedData?.metadata || {};
} else {
content = "";
metadata = {};
}
}
// Strip version suffix from prompt name for display
const promptId = apiResponse?.prompt_spec?.prompt_id || "Unnamed Prompt";
const baseName = stripVersionFromPromptId(promptId) || promptId;
// Extract model from metadata
const model = metadata?.model || "gpt-4o";
// Extract config parameters from metadata
const config: { temperature?: number; max_tokens?: number; top_p?: number } = {};
if (metadata?.temperature !== undefined) config.temperature = metadata.temperature;
if (metadata?.max_tokens !== undefined) config.max_tokens = metadata.max_tokens;
if (metadata?.top_p !== undefined) config.top_p = metadata.top_p;
// Parse the content to extract messages
// Try to parse as role-prefixed format first, then fall back to simple content
const parsedBody = parseDotpromptBody(content);
// Extract tools from metadata
const tools: Tool[] = [];
if (metadata?.tools && Array.isArray(metadata.tools)) {
metadata.tools.forEach((tool: any) => {
tools.push({
name: tool?.function?.name || "Unnamed Tool",
description: tool?.function?.description || "",
json: JSON.stringify(tool, null, 2),
});
});
}
return {
name: baseName,
model,
config,
tools,
developerMessage: parsedBody.developerMessage,
messages:
parsedBody.messages.length > 0
? parsedBody.messages
: [{ role: "user", content: content || "Enter task specifics. Use {{template_variables}} for dynamic inputs" }],
};
};
export const getVersionNumber = (promptId?: string): string => {
if (!promptId) return "1";
// Match version with dot (.v), underscore (_v), or hyphen (-v) separator