-
Notifications
You must be signed in to change notification settings - Fork 85
fix(sdk): support multimodal array inputs (e.g. image URLs) in prompt compilation #756
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2b8edc3
7a4d87f
28a0e42
0fc6ae6
49b5609
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -263,6 +263,8 @@ export class TextPromptClient extends BasePromptClient { | |
| * @public | ||
| */ | ||
| export class ChatPromptClient extends BasePromptClient { | ||
| private static readonly MAX_CACHE_SIZE = 500; | ||
| private static readonly mustacheTokenCache = new Map<string, any[]>(); | ||
| /** The original prompt response from the API */ | ||
| public readonly promptResponse: Prompt.Chat; | ||
| /** The chat messages that make up the prompt */ | ||
|
|
@@ -328,13 +330,68 @@ export class ChatPromptClient extends BasePromptClient { | |
| variables?: Record<string, string>, | ||
| placeholders?: Record<string, any>, | ||
| ): (ChatMessageOrPlaceholder | any)[] { | ||
| const messagesWithPlaceholdersReplaced: (ChatMessageOrPlaceholder | any)[] = | ||
| []; | ||
| const placeholderValues = placeholders ?? {}; | ||
| const vars = variables ?? {}; | ||
| const phs = placeholders ?? {}; | ||
|
|
||
| // 4. ZERO-ALLOCATION CHECK: Skip entirely if no injections needed | ||
| if (Object.keys(vars).length === 0 && Object.keys(phs).length === 0) { | ||
| return this.prompt.map((item) => { | ||
| if ("type" in item && item.type === ChatMessageType.ChatMessage) { | ||
| return { role: item.role, content: item.content }; | ||
| } | ||
| return item; | ||
| }); | ||
| } | ||
|
|
||
| const result: (ChatMessageOrPlaceholder | any)[] = []; | ||
| const writer = new mustache.Writer(); | ||
|
|
||
| for (const item of this.prompt) { | ||
| if ("type" in item && item.type === ChatMessageType.Placeholder) { | ||
| const placeholderValue = placeholderValues[item.name]; | ||
| if (item.type === ChatMessageType.ChatMessage) { | ||
| // 3. REFINED TYPE-GUARD: Check content type as the very first operation | ||
| if (typeof item.content !== "string") { | ||
| result.push({ | ||
| role: item.role, | ||
| content: item.content, | ||
| }); | ||
| continue; | ||
| } | ||
|
|
||
| // 2. TOKEN CACHING: Use static Map to bypass expensive parsing (Memory-Safe) | ||
| let tokens = ChatPromptClient.mustacheTokenCache.get(item.content); | ||
| if (!tokens) { | ||
| if ( | ||
| ChatPromptClient.mustacheTokenCache.size >= | ||
| ChatPromptClient.MAX_CACHE_SIZE | ||
| ) { | ||
| // FIFO Eviction: Eject the oldest entry | ||
| const oldestKey = ChatPromptClient.mustacheTokenCache | ||
| .keys() | ||
| .next().value; | ||
| if (oldestKey !== undefined) { | ||
| ChatPromptClient.mustacheTokenCache.delete(oldestKey); | ||
| } | ||
| } | ||
| tokens = mustache.parse(item.content); | ||
| ChatPromptClient.mustacheTokenCache.set(item.content, tokens); | ||
| } | ||
|
|
||
| const rendered = writer.renderTokens( | ||
| tokens, | ||
| new mustache.Context(vars), | ||
| undefined, | ||
| item.content, | ||
| ); | ||
| result.push({ | ||
| role: item.role, | ||
| content: rendered, | ||
| }); | ||
| continue; | ||
| } | ||
|
|
||
| // Handle Placeholders (preserved logic) | ||
| if (item.type === ChatMessageType.Placeholder) { | ||
| const placeholderValue = phs[item.name]; | ||
| if ( | ||
| Array.isArray(placeholderValue) && | ||
| placeholderValue.length > 0 && | ||
|
|
@@ -343,54 +400,27 @@ export class ChatPromptClient extends BasePromptClient { | |
| typeof msg === "object" && "role" in msg && "content" in msg, | ||
| ) | ||
| ) { | ||
| messagesWithPlaceholdersReplaced.push( | ||
| ...(placeholderValue as ChatMessage[]), | ||
| ); | ||
| result.push(...(placeholderValue as ChatMessage[])); | ||
| } else if ( | ||
| Array.isArray(placeholderValue) && | ||
| placeholderValue.length === 0 | ||
| ) { | ||
| // Empty array provided - skip placeholder (don't include it) | ||
| // Skip empty placeholder array | ||
| } else if (placeholderValue !== undefined) { | ||
| // Non-standard placeholder value format, just stringfiy | ||
| messagesWithPlaceholdersReplaced.push( | ||
| JSON.stringify(placeholderValue), | ||
| ); | ||
| // Stringify non-standard formats | ||
| result.push(JSON.stringify(placeholderValue)); | ||
| } else { | ||
| // Keep unresolved placeholder in the output | ||
| messagesWithPlaceholdersReplaced.push( | ||
| item as { type: ChatMessageType.Placeholder } & typeof item, | ||
| ); | ||
| // Keep unresolved placeholder | ||
| result.push(item); | ||
| } | ||
| } else if ( | ||
| "role" in item && | ||
| "content" in item && | ||
| item.type === ChatMessageType.ChatMessage | ||
| ) { | ||
| messagesWithPlaceholdersReplaced.push({ | ||
| role: item.role, | ||
| content: item.content, | ||
| }); | ||
| continue; | ||
|
Comment on lines
393
to
+416
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the original code, the compile pipeline was a two-pass process:
In the refactored code the two passes are merged into one loop. result.push(...(placeholderValue as ChatMessage[]));
// ↑ No variable substitution applied to these messagesAny user who combines // compile({ user: "Alice" }, { history: [{ role: "user", content: "Hello {{user}}" }] })
// Before: [{ role: "user", content: "Hello Alice" }]
// After: [{ role: "user", content: "Hello {{user}}" }] ← regressionThe injected messages need to go through the same Mustache rendering step before being pushed to |
||
| } | ||
|
|
||
| // Catch-all for any other message types | ||
| result.push(item); | ||
| } | ||
|
|
||
| return messagesWithPlaceholdersReplaced.map((item) => { | ||
| if ( | ||
| typeof item === "object" && | ||
| item !== null && | ||
| "role" in item && | ||
| "content" in item && | ||
| typeof item.content === 'string' | ||
| ) { | ||
| return { | ||
| ...item, | ||
| content: mustache.render(item.content, variables ?? {}), | ||
| }; | ||
| } else { | ||
| // Return placeholder or stringified value as-is | ||
| return item; | ||
| } | ||
| }); | ||
| return result; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -438,7 +468,10 @@ export class ChatPromptClient extends BasePromptClient { | |
| ...(placeholderValue as ChatMessage[]).map((msg) => { | ||
| return { | ||
| role: msg.role, | ||
| content: this._transformToLangchainVariables(msg.content), | ||
| content: | ||
| typeof msg.content === "string" | ||
| ? this._transformToLangchainVariables(msg.content) | ||
| : msg.content, | ||
| }; | ||
| }), | ||
| ); | ||
|
|
@@ -469,7 +502,10 @@ export class ChatPromptClient extends BasePromptClient { | |
| ) { | ||
| messagesWithPlaceholdersReplaced.push({ | ||
| role: item.role, | ||
| content: this._transformToLangchainVariables(item.content), | ||
| content: | ||
| typeof item.content === "string" | ||
| ? this._transformToLangchainVariables(item.content) | ||
| : item.content, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| // tsup.config.ts | ||
| import { defineConfig } from "tsup"; | ||
| var tsup_config_default = defineConfig({ | ||
| entry: ["src/index.ts"], | ||
| format: ["cjs", "esm"], | ||
| dts: true, | ||
| splitting: false, | ||
| sourcemap: true, | ||
| clean: true, | ||
| outDir: "dist", | ||
| outExtension: ({ format }) => ({ | ||
| js: format === "cjs" ? ".cjs" : ".mjs" | ||
| }) | ||
| }); | ||
| export { | ||
| tsup_config_default as default | ||
| }; | ||
| //# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidHN1cC5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9faW5qZWN0ZWRfZmlsZW5hbWVfXyA9IFwiQzpcXFxcVXNlcnNcXFxcUFJBVkVFTiBSQUlcXFxcbGFuZ2Z1c2UtanNcXFxccGFja2FnZXNcXFxcY2xpZW50XFxcXHRzdXAuY29uZmlnLnRzXCI7Y29uc3QgX19pbmplY3RlZF9kaXJuYW1lX18gPSBcIkM6XFxcXFVzZXJzXFxcXFBSQVZFRU4gUkFJXFxcXGxhbmdmdXNlLWpzXFxcXHBhY2thZ2VzXFxcXGNsaWVudFwiO2NvbnN0IF9faW5qZWN0ZWRfaW1wb3J0X21ldGFfdXJsX18gPSBcImZpbGU6Ly8vQzovVXNlcnMvUFJBVkVFTiUyMFJBSS9sYW5nZnVzZS1qcy9wYWNrYWdlcy9jbGllbnQvdHN1cC5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidHN1cFwiO1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBlbnRyeTogW1wic3JjL2luZGV4LnRzXCJdLFxuICBmb3JtYXQ6IFtcImNqc1wiLCBcImVzbVwiXSxcbiAgZHRzOiB0cnVlLFxuICBzcGxpdHRpbmc6IGZhbHNlLFxuICBzb3VyY2VtYXA6IHRydWUsXG4gIGNsZWFuOiB0cnVlLFxuICBvdXREaXI6IFwiZGlzdFwiLFxuICBvdXRFeHRlbnNpb246ICh7IGZvcm1hdCB9KSA9PiAoe1xuICAgIGpzOiBmb3JtYXQgPT09IFwiY2pzXCIgPyBcIi5janNcIiA6IFwiLm1qc1wiLFxuICB9KSxcbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE0UyxTQUFTLG9CQUFvQjtBQUV6VSxJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUMxQixPQUFPLENBQUMsY0FBYztBQUFBLEVBQ3RCLFFBQVEsQ0FBQyxPQUFPLEtBQUs7QUFBQSxFQUNyQixLQUFLO0FBQUEsRUFDTCxXQUFXO0FBQUEsRUFDWCxXQUFXO0FBQUEsRUFDWCxPQUFPO0FBQUEsRUFDUCxRQUFRO0FBQUEsRUFDUixjQUFjLENBQUMsRUFBRSxPQUFPLE9BQU87QUFBQSxJQUM3QixJQUFJLFdBQVcsUUFBUSxTQUFTO0FBQUEsRUFDbEM7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo= | ||
|
Comment on lines
+1
to
+18
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This is a generated More critically, the embedded base64 source map decodes to a Windows absolute path that leaks the developer's local machine username and directory layout: This file should be deleted from the branch and added to |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2151,5 +2151,128 @@ Configuration: | |
| expect(formattedMessages[1].content).toBe(expectedUser); | ||
| }); | ||
| }); | ||
|
|
||
| it("should handle multimodal array inputs for ChatMessage types", () => { | ||
| const promptClient = new ChatPromptClient({ | ||
| name: "multimodal-test", | ||
| type: "chat", | ||
| version: 1, | ||
| prompt: [ | ||
| { | ||
| type: ChatMessageType.ChatMessage, | ||
| role: "user", | ||
| content: { | ||
| attachments: [ | ||
| { | ||
| type: "image_url", | ||
| image_url: { url: "https://example.com/image.png" }, | ||
| }, | ||
| ], | ||
| } as any, | ||
| }, | ||
| ], | ||
| config: {}, | ||
| labels: [], | ||
| tags: [], | ||
| }); | ||
|
|
||
| // After fix: Should return the multimodal content as-is without crashing | ||
| const compiled = promptClient.compile(); | ||
| expect(compiled[0].content).toEqual({ | ||
| attachments: [ | ||
| { | ||
| type: "image_url", | ||
| image_url: { url: "https://example.com/image.png" }, | ||
| }, | ||
| ], | ||
| }); | ||
|
Comment on lines
+2180
to
+2188
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
To cover the primary fix, you should also test the main-loop path: // Also test when variables are provided (exercises the main loop type guard)
const compiledWithVars = promptClient.compile({ someVar: "value" });
expect(compiledWithVars[0].content).toEqual({
attachments: [
{
type: "image_url",
image_url: { url: "https://example.com/image.png" },
},
],
});Without this, the regression test does not guard against future regressions specifically in the main loop path. |
||
| }); | ||
|
|
||
| it("should handle multimodal array inputs in the main loop with variables", () => { | ||
| const promptClient = new ChatPromptClient({ | ||
| name: "multimodal-test", | ||
| type: "chat", | ||
| version: 1, | ||
| prompt: [ | ||
| { | ||
| type: ChatMessageType.ChatMessage, | ||
| role: "user", | ||
| content: { | ||
| attachments: [ | ||
| { | ||
| type: "image_url", | ||
| image_url: { url: "https://example.com/image.png" }, | ||
| }, | ||
| ], | ||
| } as any, | ||
| }, | ||
| { | ||
| type: ChatMessageType.ChatMessage, | ||
| role: "user", | ||
| content: "Dummy: {{dummy}}", | ||
| }, | ||
| ], | ||
| config: {}, | ||
| labels: [], | ||
| tags: [], | ||
| }); | ||
|
|
||
| // Passing a variable forces execution into the main loop | ||
| const compiled = promptClient.compile({ dummy: "test" }); | ||
| expect(compiled[0].content).toEqual({ | ||
| attachments: [ | ||
| { | ||
| type: "image_url", | ||
| image_url: { url: "https://example.com/image.png" }, | ||
| }, | ||
| ], | ||
| }); | ||
| expect(compiled[1].content).toEqual("Dummy: test"); | ||
| }); | ||
|
|
||
| it("should handle multimodal content in getLangchainPrompt safely", () => { | ||
| const promptClient = new ChatPromptClient({ | ||
| name: "multimodal-langchain-test", | ||
| type: "chat", | ||
| version: 1, | ||
| prompt: [ | ||
| { | ||
| type: ChatMessageType.ChatMessage, | ||
| role: "user", | ||
| content: { | ||
| attachments: [ | ||
| { | ||
| type: "image_url", | ||
| image_url: { url: "https://example.com/image.png" }, | ||
| }, | ||
| ], | ||
| } as any, | ||
| }, | ||
| ], | ||
| config: {}, | ||
| labels: [], | ||
| tags: [], | ||
| }); | ||
| }); | ||
|
Comment on lines
+2233
to
+2256
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The test creates a The fix added in The method must be called and its output verified: it("should handle multimodal content in getLangchainPrompt safely", () => {
const promptClient = new ChatPromptClient({ /* same setup */ });
// This call would throw before the fix
const result = promptClient.getLangchainPrompt();
expect(result[0].content).toEqual({
attachments: [
{
type: "image_url",
image_url: { url: "https://example.com/image.png" },
},
],
});
}); |
||
|
|
||
| it("should handle multimodal content in getLangchainPrompt for non-ChatMessage items", () => { | ||
| const promptClient = new ChatPromptClient({ | ||
| name: "multimodal-non-chat-test", | ||
| type: "chat", | ||
| version: 1, | ||
| prompt: [ | ||
| { | ||
| type: ChatMessageType.Placeholder, | ||
| name: "multimodal_placeholder", | ||
| }, | ||
| ], | ||
| config: {}, | ||
| labels: [], | ||
| tags: [], | ||
| }); | ||
|
|
||
| const result = promptClient.getLangchainPrompt(); | ||
| expect(result[0]).toEqual(["placeholder", "{multimodal_placeholder}"]); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mustacheTokenCacheis astaticMapwith no maximum size and no eviction policy. Because it lives for the entire lifetime of the process, applications that process many distinct prompt templates (e.g. dynamically generated templates, per-tenant variations, or prompts whose text changes over time) will accumulate entries indefinitely, potentially causing out-of-memory failures in long-running servers.A
Mapin JavaScript preserves insertion order, so implementing a simple bounded cache is straightforward: before adding a new entry, check if the map has reached its maximum size and evict the oldest key (the first entry returned by the iterator) if so. A cap of a few hundred entries is typically enough to warm the common case while preventing unbounded growth.