diff --git a/.github/workflows/check-pr.yaml b/.github/workflows/check-pr.yaml index 4e13eaa7..febbd8e6 100644 --- a/.github/workflows/check-pr.yaml +++ b/.github/workflows/check-pr.yaml @@ -75,46 +75,3 @@ jobs: run: npm run build - name: Check build outputs run: npm run check-build - - publish-check: - name: Publish Simulation - runs-on: ubuntu-latest - needs: [format-check, type-check, test, build] - if: github.event_name == 'pull_request' - steps: - - name: Checkout code - uses: actions/checkout@v6 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: "npm" - - name: Install dependencies - run: npm ci - - name: Build package - run: npm run build - - name: Determine npm tag for dry-run - id: tag - run: | - VERSION=$(node -p "require('./package.json').version") - if [[ "$VERSION" =~ -alpha ]]; then - echo "tag=alpha" >> $GITHUB_OUTPUT - elif [[ "$VERSION" =~ -beta ]]; then - echo "tag=beta" >> $GITHUB_OUTPUT - elif [[ "$VERSION" =~ -rc ]]; then - echo "tag=next" >> $GITHUB_OUTPUT - elif [[ "$VERSION" =~ -canary ]]; then - echo "tag=canary" >> $GITHUB_OUTPUT - elif [[ "$VERSION" =~ - ]]; then - echo "tag=next" >> $GITHUB_OUTPUT - else - echo "tag=latest" >> $GITHUB_OUTPUT - fi - echo "Detected version: $VERSION, using tag: $(cat $GITHUB_OUTPUT | grep tag | cut -d= -f2)" - - name: Simulate npm publish - run: npm publish --dry-run --tag ${{ steps.tag.outputs.tag }} - - name: Check publishable files - run: | - echo "📦 Checking package contents:" - npm pack --dry-run 2>/dev/null | head -20 || true - echo "✅ Package can be published successfully" diff --git a/.github/workflows/npm-publish-npm-packages.yml b/.github/workflows/npm-publish-npm-packages.yml index cc11084e..183cbbc9 100644 --- a/.github/workflows/npm-publish-npm-packages.yml +++ b/.github/workflows/npm-publish-npm-packages.yml @@ -59,5 +59,7 @@ jobs: echo "tag=latest" >> $GITHUB_OUTPUT echo "Publishing as 'latest' tag for stable version $VERSION" fi + - name: Simulate npm publish + run: npm publish --dry-run --tag ${{ steps.tag.outputs.tag }} - name: Publish to npm run: npm publish --provenance --access public --tag ${{ steps.tag.outputs.tag }} diff --git a/API_REFERENCE.md b/API_REFERENCE.md index 17821029..3d1481ba 100644 --- a/API_REFERENCE.md +++ b/API_REFERENCE.md @@ -1040,15 +1040,16 @@ Model-specific configuration options. **Properties:** -| Property | Type | Default | Description | -| ------------------ | ---------------------- | ---------- | ------------------------------------------------------------------------------------------------------ | -| `modelVersion` | `string` | `'latest'` | Specific model version | -| `includeReasoning` | `boolean` | - | Whether to include assistant reasoning parts in SAP prompt conversion (may contain internal reasoning) | -| `modelParams` | `ModelParams` | - | Model generation parameters | -| `masking` | `MaskingModule` | - | Data masking configuration (DPI) | -| `filtering` | `FilteringModule` | - | Content filtering configuration | -| `responseFormat` | `ResponseFormatConfig` | - | Response format specification | -| `tools` | `ChatCompletionTool[]` | - | Tool definitions in SAP AI SDK format | +| Property | Type | Default | Description | +| ---------------------------- | ---------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- | +| `modelVersion` | `string` | `'latest'` | Specific model version | +| `includeReasoning` | `boolean` | - | Whether to include assistant reasoning parts in SAP prompt conversion (may contain internal reasoning) | +| `escapeTemplatePlaceholders` | `boolean` | `true` | Escape template delimiters (`{{`, `{%`, `{#`) in message content to prevent conflicts with SAP orchestration templating | +| `modelParams` | `ModelParams` | - | Model generation parameters | +| `masking` | `MaskingModule` | - | Data masking configuration (DPI) | +| `filtering` | `FilteringModule` | - | Content filtering configuration | +| `responseFormat` | `ResponseFormatConfig` | - | Response format specification | +| `tools` | `ChatCompletionTool[]` | - | Tool definitions in SAP AI SDK format | **Example:** @@ -1079,6 +1080,8 @@ const settings: SAPAISettings = { }; ``` +> **Note:** The `escapeTemplatePlaceholders` option is enabled by default to prevent SAP AI Core orchestration API errors when content contains template syntax (`{{variable}}`, `{% if %}`, `{# comment #}`). Set to `false` only if you intentionally use SAP orchestration templating features. See [Troubleshooting - Problem: Template Placeholder Conflicts](./TROUBLESHOOTING.md#problem-template-placeholder-conflicts) for details. + --- ### `ModelParams` diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 9eca967f..f15002c6 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -5,17 +5,18 @@ Provider. ## Quick Reference -| Issue | Section | -| --------------------- | --------------------------------------------------------------------- | -| 401 Unauthorized | [Authentication Issues](#problem-authentication-failed-or-401-errors) | -| 403 Forbidden | [Authentication Issues](#problem-403-forbidden) | -| 404 Not Found | [Model and Deployment Issues](#problem-404-modeldeployment-not-found) | -| 400 Bad Request | [API Errors](#problem-400-bad-request) | -| 429 Rate Limit | [API Errors](#problem-429-rate-limit-exceeded) | -| 500-504 Server Errors | [API Errors](#problem-500502503504-server-errors) | -| Tools not called | [Tool Calling Issues](#problem-tools-not-being-called) | -| Stream issues | [Streaming Issues](#problem-streaming-not-working-or-incomplete) | -| Slow responses | [Performance Issues](#problem-slow-response-times) | +| Issue | Section | +| --------------------- | ---------------------------------------------------------------------------------- | +| 401 Unauthorized | [Authentication Issues](#problem-authentication-failed-or-401-errors) | +| 403 Forbidden | [Authentication Issues](#problem-403-forbidden) | +| 404 Not Found | [Model and Deployment Issues](#problem-404-modeldeployment-not-found) | +| 400 Bad Request | [API Errors](#problem-400-bad-request) | +| 400 Template errors | [Problem: Template Placeholder Conflicts](#problem-template-placeholder-conflicts) | +| 429 Rate Limit | [API Errors](#problem-429-rate-limit-exceeded) | +| 500-504 Server Errors | [API Errors](#problem-500502503504-server-errors) | +| Tools not called | [Tool Calling Issues](#problem-tools-not-being-called) | +| Stream issues | [Streaming Issues](#problem-streaming-not-working-or-incomplete) | +| Slow responses | [Performance Issues](#problem-slow-response-times) | ## Common Problems (Top 5) @@ -60,6 +61,7 @@ below. - [API Errors](#api-errors) - [Parsing SAP Error Metadata (v3.0.0+)](#parsing-sap-error-metadata-v300) - [Problem: 400 Bad Request](#problem-400-bad-request) + - [Problem: Template Placeholder Conflicts](#problem-template-placeholder-conflicts) - [Problem: 429 Rate Limit Exceeded](#problem-429-rate-limit-exceeded) - [Problem: 500/502/503/504 Server Errors](#problem-500502503504-server-errors) - [Model and Deployment Issues](#model-and-deployment-issues) @@ -165,6 +167,54 @@ request, incompatible features - Check API Reference for valid parameter ranges - Enable verbose logging to see exact request +### Problem: Template Placeholder Conflicts + +**Symptoms:** HTTP 400 with error messages like: + +- `"Unused parameters: [...]"` +- Template parsing errors when message content contains `{{`, `{%`, or `{#` + +**Cause:** SAP AI Core's orchestration API uses template syntax +(`{{variable}}`, `{{?variable}}`, `{% if %}`, `{# comment #}`) for prompt templating. When tool results or +message content contains these patterns, the API incorrectly interprets them as template directives. + +**Solutions:** + +The `escapeTemplatePlaceholders` option is **enabled by default**, +which should prevent this issue. If you still encounter it, verify that you +haven't explicitly disabled escaping: + +```typescript +// Escaping is enabled by default - no configuration needed +const provider = createSAPAIProvider(); + +// If you need to disable escaping (e.g., to use SAP orchestration templating) +const provider = createSAPAIProvider({ + defaultSettings: { + escapeTemplatePlaceholders: false, // Opt-out of automatic escaping + }, +}); +``` + +**How it works:** + +The option inserts a zero-width space (U+200B) between template opening delimiters +(`{{` becomes `{\u200B{`, `{%` becomes `{\u200B%`, `{#` becomes `{\u200B#`), +breaking the pattern while keeping content visually unchanged. JSON structures +with `}}` (closing braces) are preserved. + +**Manual escaping utilities:** + +```typescript +import { escapeOrchestrationPlaceholders, unescapeOrchestrationPlaceholders } from "@jerome-benoit/sap-ai-provider"; + +const escaped = escapeOrchestrationPlaceholders("Use {{?question}} to prompt"); +// Result: "Use {\u200B{?question}} to prompt" + +const restored = unescapeOrchestrationPlaceholders(escaped); +// Result: "Use {{?question}} to prompt" +``` + ### Problem: 429 Rate Limit Exceeded **Solutions:** diff --git a/src/convert-to-sap-messages.test.ts b/src/convert-to-sap-messages.test.ts index 854b69cd..c6ab45b7 100644 --- a/src/convert-to-sap-messages.test.ts +++ b/src/convert-to-sap-messages.test.ts @@ -8,7 +8,11 @@ import { InvalidPromptError } from "@ai-sdk/provider"; import { Buffer } from "node:buffer"; import { describe, expect, it } from "vitest"; -import { convertToSAPMessages } from "./convert-to-sap-messages"; +import { + convertToSAPMessages, + escapeOrchestrationPlaceholders, + unescapeOrchestrationPlaceholders, +} from "./convert-to-sap-messages"; const createUserPrompt = (text: string): LanguageModelV3Prompt => [ { content: [{ text, type: "text" }], role: "user" }, @@ -799,4 +803,333 @@ describe("convertToSAPMessages", () => { expect(() => convertToSAPMessages(prompt)).toThrow("Unsupported role: unsupported_role"); }); }); + + describe("template placeholder escaping", () => { + const ZERO_WIDTH_SPACE = "\u200B"; + + describe("escapeOrchestrationPlaceholders", () => { + it("should escape opening double braces", () => { + const input = "Use {{variable}} in your prompt"; + const result = escapeOrchestrationPlaceholders(input); + // Only {{ is escaped, }} is left as-is to preserve JSON compatibility + expect(result).toBe(`Use {${ZERO_WIDTH_SPACE}{variable}} in your prompt`); + expect(result).not.toContain("{{"); + }); + + it("should escape optional placeholder syntax", () => { + const input = "Question: {{?question}}"; + const result = escapeOrchestrationPlaceholders(input); + expect(result).toBe(`Question: {${ZERO_WIDTH_SPACE}{?question}}`); + }); + + it("should escape multiple placeholders", () => { + const input = "{{a}} and {{b}} and {{?c}}"; + const result = escapeOrchestrationPlaceholders(input); + expect(result).not.toContain("{{"); + // Only opening braces are escaped, so 3 zero-width spaces (one per {{) + expect(result.match(new RegExp(ZERO_WIDTH_SPACE, "g"))).toHaveLength(3); + }); + + it("should handle text without placeholders", () => { + const input = "Normal text without any braces"; + expect(escapeOrchestrationPlaceholders(input)).toBe(input); + }); + + it("should handle single braces (no escaping needed)", () => { + const input = "JSON object: { key: value }"; + expect(escapeOrchestrationPlaceholders(input)).toBe(input); + }); + + it("should handle empty string", () => { + expect(escapeOrchestrationPlaceholders("")).toBe(""); + }); + + it("should handle nested braces and preserve JSON structure", () => { + const input = "{{{nested}}}"; + const result = escapeOrchestrationPlaceholders(input); + // Only {{ sequences are escaped; }} is left alone to preserve JSON + expect(result).not.toContain("{{"); + // Should still contain }} since we don't escape closing braces + expect(result).toContain("}}"); + }); + + it("should preserve JSON object structure", () => { + const input = '{"outer": {"inner": "value"}}'; + const result = escapeOrchestrationPlaceholders(input); + // No {{ in JSON, so nothing should be escaped + expect(result).toBe(input); + // JSON should remain valid + expect(() => JSON.parse(result) as unknown).not.toThrow(); + }); + + it("should escape Jinja2 block statements ({%)", () => { + const input = "{% for item in items %}{{ item }}{% endfor %}"; + const result = escapeOrchestrationPlaceholders(input); + expect(result).toBe( + `{${ZERO_WIDTH_SPACE}% for item in items %}{${ZERO_WIDTH_SPACE}{ item }}{${ZERO_WIDTH_SPACE}% endfor %}`, + ); + expect(result).not.toContain("{%"); + expect(result).not.toContain("{{"); + }); + + it("should escape Jinja2 comments ({#)", () => { + const input = "{# This is a Jinja2 comment #}"; + const result = escapeOrchestrationPlaceholders(input); + expect(result).toBe(`{${ZERO_WIDTH_SPACE}# This is a Jinja2 comment #}`); + expect(result).not.toContain("{#"); + }); + + it("should escape all Jinja2 delimiters in mixed content", () => { + const input = "{{ var }} {% if cond %} {# comment #}"; + const result = escapeOrchestrationPlaceholders(input); + expect(result).not.toContain("{{"); + expect(result).not.toContain("{%"); + expect(result).not.toContain("{#"); + // 3 delimiters escaped = 3 zero-width spaces + expect(result.match(new RegExp(ZERO_WIDTH_SPACE, "g"))).toHaveLength(3); + }); + + it("should handle edge case: {{{ (triple brace)", () => { + const input = "{{{nested}}}"; + const result = escapeOrchestrationPlaceholders(input); + // First {{ is escaped, remaining { is just a brace + expect(result).toBe(`{${ZERO_WIDTH_SPACE}{${ZERO_WIDTH_SPACE}{nested}}}`); + }); + + it("should handle overlapping delimiters ({{%)", () => { + const input = "{{%mixed"; + const result = escapeOrchestrationPlaceholders(input); + // Both {{ and {% delimiters overlap - the loop escapes {{ first, + // then the remaining {% is also escaped + expect(result).toBe(`{${ZERO_WIDTH_SPACE}{${ZERO_WIDTH_SPACE}%mixed`); + }); + }); + + describe("unescapeOrchestrationPlaceholders", () => { + it("should restore original placeholder syntax", () => { + const original = "Use {{variable}} in your prompt"; + const escaped = escapeOrchestrationPlaceholders(original); + const restored = unescapeOrchestrationPlaceholders(escaped); + expect(restored).toBe(original); + }); + + it("should restore multiple placeholders", () => { + const original = "{{a}} and {{b}} and {{?c}}"; + const escaped = escapeOrchestrationPlaceholders(original); + const restored = unescapeOrchestrationPlaceholders(escaped); + expect(restored).toBe(original); + }); + + it("should handle text without escaped placeholders", () => { + const input = "Normal text"; + expect(unescapeOrchestrationPlaceholders(input)).toBe(input); + }); + + it("should handle empty string", () => { + expect(unescapeOrchestrationPlaceholders("")).toBe(""); + }); + + it("should restore Jinja2 block statements ({%)", () => { + const original = "{% for item in items %}{% endfor %}"; + const escaped = escapeOrchestrationPlaceholders(original); + const restored = unescapeOrchestrationPlaceholders(escaped); + expect(restored).toBe(original); + }); + + it("should restore Jinja2 comments ({#)", () => { + const original = "{# comment #}"; + const escaped = escapeOrchestrationPlaceholders(original); + const restored = unescapeOrchestrationPlaceholders(escaped); + expect(restored).toBe(original); + }); + + it("should restore all Jinja2 delimiters in mixed content", () => { + const original = "{{ var }} {% if x %} {# note #}"; + const escaped = escapeOrchestrationPlaceholders(original); + const restored = unescapeOrchestrationPlaceholders(escaped); + expect(restored).toBe(original); + }); + }); + + describe("convertToSAPMessages with escapeTemplatePlaceholders", () => { + it("should escape template placeholders by default", () => { + const prompt: LanguageModelV3Prompt = [ + { content: [{ text: "Use {{?question}} to ask", type: "text" }], role: "user" }, + ]; + const result = convertToSAPMessages(prompt); + const content = (result[0] as { content: string }).content; + expect(content).not.toContain("{{"); + expect(content).toContain(ZERO_WIDTH_SPACE); + }); + + it("should preserve placeholders when escaping disabled", () => { + const prompt: LanguageModelV3Prompt = [ + { content: [{ text: "Use {{?question}} to ask", type: "text" }], role: "user" }, + ]; + const result = convertToSAPMessages(prompt, { escapeTemplatePlaceholders: false }); + expect((result[0] as { content: string }).content).toContain("{{?question}}"); + }); + + it("should escape user message text", () => { + const prompt: LanguageModelV3Prompt = [ + { content: [{ text: "Use {{?question}} to ask", type: "text" }], role: "user" }, + ]; + const result = convertToSAPMessages(prompt, { escapeTemplatePlaceholders: true }); + const content = (result[0] as { content: string }).content; + expect(content).not.toContain("{{"); + expect(content).toContain(ZERO_WIDTH_SPACE); + }); + + it("should escape system message content", () => { + const prompt: LanguageModelV3Prompt = [ + { content: "You are using {{model}} as your LLM", role: "system" }, + ]; + const result = convertToSAPMessages(prompt, { escapeTemplatePlaceholders: true }); + const content = (result[0] as { content: string }).content; + expect(content).not.toContain("{{"); + }); + + it("should escape assistant message text", () => { + const prompt: LanguageModelV3Prompt = [ + { content: [{ text: "Try using {{?input}}", type: "text" }], role: "assistant" }, + ]; + const result = convertToSAPMessages(prompt, { escapeTemplatePlaceholders: true }); + const content = (result[0] as { content: string }).content; + expect(content).not.toContain("{{"); + }); + + it("should escape tool result content", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + output: { + type: "json" as const, + value: { syntax: "Use {{variable}}", template: "{{?question}}" }, + }, + toolCallId: "call_123", + toolName: "get_schema", + type: "tool-result", + }, + ], + role: "tool", + }, + ]; + const result = convertToSAPMessages(prompt, { escapeTemplatePlaceholders: true }); + const content = (result[0] as { content: string }).content; + expect(content).not.toContain("{{"); + // Verify it's still valid JSON + expect(() => JSON.parse(content) as unknown).not.toThrow(); + }); + + it("should escape tool call arguments", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + input: { template: "{{?variable}}" }, + toolCallId: "call_123", + toolName: "process", + type: "tool-call", + }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt, { escapeTemplatePlaceholders: true }); + const message = result[0] as { tool_calls: { function: { arguments: string } }[] }; + const args = message.tool_calls[0]?.function.arguments ?? ""; + expect(args).not.toContain("{{"); + // JSON structure (with }}) should still be valid + expect(() => JSON.parse(args) as unknown).not.toThrow(); + }); + + it("should escape reasoning content when included", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { text: "Thinking about {{placeholder}}...", type: "reasoning" }, + { text: "Final answer", type: "text" }, + ], + role: "assistant", + }, + ]; + const result = convertToSAPMessages(prompt, { + escapeTemplatePlaceholders: true, + includeReasoning: true, + }); + const content = (result[0] as { content: string }).content; + expect(content).toContain(""); + expect(content).not.toContain("{{placeholder}}"); + expect(content).toContain(ZERO_WIDTH_SPACE); + }); + + it("should handle complex AI agent tool content with placeholder syntax", () => { + // AI coding agents often have tool schemas with {{?variable}} syntax + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { + output: { + type: "json" as const, + value: { + schema: { + properties: { + items: { + items: { + properties: { + variable1: { description: "First variable", type: "string" }, + }, + }, + type: "array", + }, + }, + }, + template: "Use {{?variable2}} to prompt the user", + }, + }, + toolCallId: "call_tool_123", + toolName: "process", + type: "tool-result", + }, + ], + role: "tool", + }, + ]; + const result = convertToSAPMessages(prompt, { escapeTemplatePlaceholders: true }); + const content = (result[0] as { content: string }).content; + + // Verify placeholders are escaped (only opening braces) + expect(content).not.toContain("{{?variable2}}"); + expect(content).not.toContain("{{"); + + // Verify it's still valid JSON that can be parsed + expect(() => JSON.parse(content) as unknown).not.toThrow(); + }); + + it("should not modify multi-modal content parts (images)", () => { + const prompt: LanguageModelV3Prompt = [ + { + content: [ + { text: "Check {{?image}}", type: "text" }, + { data: "base64data", mediaType: "image/png", type: "file" }, + ], + role: "user", + }, + ]; + const result = convertToSAPMessages(prompt, { escapeTemplatePlaceholders: true }); + const message = result[0] as { + content: { image_url?: { url: string }; text?: string; type: string }[]; + }; + + // Text should be escaped + const textPart = message.content.find((c) => c.type === "text"); + expect(textPart?.text).not.toContain("{{"); + + // Image URL should be unchanged + const imagePart = message.content.find((c) => c.type === "image_url"); + expect(imagePart?.image_url?.url).toBe("data:image/png;base64,base64data"); + }); + }); + }); }); diff --git a/src/convert-to-sap-messages.ts b/src/convert-to-sap-messages.ts index ae68f1d2..143ae234 100644 --- a/src/convert-to-sap-messages.ts +++ b/src/convert-to-sap-messages.ts @@ -24,19 +24,39 @@ import { Buffer } from "node:buffer"; * @see {@link https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/orchestration SAP AI Core Orchestration} */ -/** - * Options for converting Vercel AI SDK prompts to SAP AI SDK messages. - */ +/** Options for converting Vercel AI SDK prompts to SAP AI SDK messages. */ export interface ConvertToSAPMessagesOptions { /** - * Whether to include assistant reasoning parts in the converted messages. - * - * When false (default), reasoning content is dropped. - * When true, reasoning is preserved as `...` markers. + * Escape template delimiters (`{{`, `{%`, `{#`) to prevent SAP orchestration template conflicts. + * @default true + */ + readonly escapeTemplatePlaceholders?: boolean; + + /** + * Include assistant reasoning parts as `...` markers. + * @default false */ readonly includeReasoning?: boolean; } +/** + * Zero-width space used to break template delimiters in orchestration content. + * @internal + */ +const ZERO_WIDTH_SPACE = "\u200B"; + +/** + * Regex matching template opening delimiters: `{{`, `{%`, `{#`. + * @internal + */ +const JINJA2_DELIMITERS_PATTERN = /\{([{%#])/g; + +/** + * Regex matching escaped template delimiters for unescaping. + * @internal + */ +const JINJA2_DELIMITERS_ESCAPED_PATTERN = new RegExp(`\\{${ZERO_WIDTH_SPACE}([{%#])`, "g"); + /** * Multi-modal content item for user messages. * @internal @@ -70,6 +90,15 @@ export function convertToSAPMessages( ): ChatMessage[] { const messages: ChatMessage[] = []; const includeReasoning = options.includeReasoning ?? false; + const escapeTemplatePlaceholders = options.escapeTemplatePlaceholders ?? true; + + /** + * Conditionally escapes text content based on the escapeTemplatePlaceholders option. + * @param text - The text to potentially escape. + * @returns The escaped or original text. + */ + const maybeEscape = (text: string): string => + escapeTemplatePlaceholders ? escapeOrchestrationPlaceholders(text) : text; for (const message of prompt) { switch (message.role) { @@ -85,12 +114,12 @@ export function convertToSAPMessages( switch (part.type) { case "reasoning": { if (includeReasoning && part.text) { - text += `${part.text}`; + text += `${maybeEscape(part.text)}`; } break; } case "text": { - text += part.text; + text += maybeEscape(part.text); break; } case "tool-call": { @@ -107,9 +136,10 @@ export function convertToSAPMessages( argumentsJson = JSON.stringify(part.input); } + // Escape tool call arguments if needed (they may contain placeholder syntax) toolCalls.push({ function: { - arguments: argumentsJson, + arguments: maybeEscape(argumentsJson), name: part.toolName, }, id: part.toolCallId, @@ -131,7 +161,7 @@ export function convertToSAPMessages( case "system": { const systemMessage: SystemChatMessage = { - content: message.content, + content: maybeEscape(message.content), role: "system", }; messages.push(systemMessage); @@ -141,8 +171,9 @@ export function convertToSAPMessages( case "tool": { for (const part of message.content) { if (part.type === "tool-result") { + const serializedOutput = JSON.stringify(part.output); const toolMessage: ToolChatMessage = { - content: JSON.stringify(part.output), + content: maybeEscape(serializedOutput), role: "tool", tool_call_id: part.toolCallId, }; @@ -221,7 +252,7 @@ export function convertToSAPMessages( } case "text": { contentParts.push({ - text: part.text, + text: maybeEscape(part.text), type: "text", }); break; @@ -262,3 +293,30 @@ export function convertToSAPMessages( return messages; } + +/** + * Escapes template delimiters (`{{`, `{%`, `{#`) by inserting zero-width spaces. + * @param text - The text content to escape. + * @returns Text with delimiters escaped (e.g., `{{` → `{\u200B{`). + */ +export function escapeOrchestrationPlaceholders(text: string): string { + if (!text) return text; + // Loop to handle overlapping patterns like {{{ where {{ appears twice + let result = text; + let previous: string; + do { + previous = result; + result = result.replace(JINJA2_DELIMITERS_PATTERN, `{${ZERO_WIDTH_SPACE}$1`); + } while (result !== previous); + return result; +} + +/** + * Reverses escaping by removing zero-width spaces from template delimiters. + * @param text - The escaped text content. + * @returns Original text with `{{`, `{%`, `{#` restored. + */ +export function unescapeOrchestrationPlaceholders(text: string): string { + if (!text) return text; + return text.replace(JINJA2_DELIMITERS_ESCAPED_PATTERN, "{$1"); +} diff --git a/src/index.ts b/src/index.ts index 3909a022..15e28fb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,14 @@ * Wraps the SAP AI SDK to provide Vercel AI SDK-compatible interfaces. */ +/** + * Utility functions for escaping template delimiters (`{{`, `{%`, `{#`) in orchestration content. + */ +export { + escapeOrchestrationPlaceholders, + unescapeOrchestrationPlaceholders, +} from "./convert-to-sap-messages.js"; + /** * Embedding model class for generating vector embeddings via SAP AI Core. */ diff --git a/src/sap-ai-language-model.ts b/src/sap-ai-language-model.ts index 5fd7bce2..603b9f8d 100644 --- a/src/sap-ai-language-model.ts +++ b/src/sap-ai-language-model.ts @@ -640,6 +640,8 @@ export class SAPAILanguageModel implements LanguageModelV3 { const warnings: SharedV3Warning[] = []; const messages = convertToSAPMessages(options.prompt, { + escapeTemplatePlaceholders: + sapOptions?.escapeTemplatePlaceholders ?? this.settings.escapeTemplatePlaceholders ?? true, includeReasoning: sapOptions?.includeReasoning ?? this.settings.includeReasoning ?? false, }); diff --git a/src/sap-ai-provider-options.ts b/src/sap-ai-provider-options.ts index 549b6bf4..ad82da7b 100644 --- a/src/sap-ai-provider-options.ts +++ b/src/sap-ai-provider-options.ts @@ -126,6 +126,8 @@ export function validateModelParamsWithWarnings( export const sapAILanguageModelProviderOptions = lazySchema(() => zodSchema( z.object({ + /** Escape template delimiters (`{{`, `{%`, `{#`) to prevent SAP orchestration template conflicts. */ + escapeTemplatePlaceholders: z.boolean().optional(), /** Whether to include assistant reasoning parts in the response. */ includeReasoning: z.boolean().optional(), /** Model generation parameters for this specific call. */ diff --git a/src/sap-ai-settings.ts b/src/sap-ai-settings.ts index e3a6f4d3..37a23f9a 100644 --- a/src/sap-ai-settings.ts +++ b/src/sap-ai-settings.ts @@ -18,6 +18,12 @@ export type SAPAIModelId = ChatModel; * Controls model parameters, data masking, content filtering, and tool usage. */ export interface SAPAISettings { + /** + * Escape template delimiters (`{{`, `{%`, `{#`) to prevent SAP orchestration template conflicts. + * @default true + */ + readonly escapeTemplatePlaceholders?: boolean; + /** Filtering configuration for input and output content safety. */ readonly filtering?: FilteringModule;