From 31535a859acc1b3e75bb92fe36e696194161e966 Mon Sep 17 00:00:00 2001 From: yoadelkayam Date: Tue, 27 Jan 2026 01:06:43 +0200 Subject: [PATCH 1/5] feat: add multi-modal content support to PLP protocol and SDKs - Add ContentPart schemas (TextContent, ImageContent) to OpenAPI spec - Add PromptContent type (string | ContentPart[]) for backward compat - Update JSON Schema with multi-modal definitions - TypeScript SDK: add types and helper functions (isMultiModal, normalizeContent, getTextContent) - Python SDK: add classes and helper functions with full type hints - Add comprehensive tests for both SDKs - Update README with Echostash ecosystem diagram Closes #2, #3, #4, #5, #6 Co-Authored-By: Claude Opus 4.5 --- README.md | 38 ++++ sdks/js/src/index.test.ts | 146 +++++++++++++- sdks/js/src/index.ts | 92 ++++++++- sdks/python/src/plp_client/__init__.py | 34 +++- sdks/python/src/plp_client/client.py | 173 +++++++++++++++- sdks/python/tests/test_client.py | 262 ++++++++++++++++++++++++- spec/openapi.yaml | 103 +++++++++- spec/plp-schema.json | 103 +++++++++- 8 files changed, 927 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index e82273e..a90c62a 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,44 @@ Want to build a PLP-compliant server or client? --- +## 🌐 Echostash Ecosystem + +PLP is part of the **Echostash ecosystem** - a complete toolkit for prompt engineering: + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ECHOSTASH ECOSYSTEM β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ EchoIDE β”‚ β”‚ Echostash β”‚ β”‚ Your App β”‚ β”‚ +β”‚ β”‚ (Develop) │────▢│ (Manage) │◀────│ (Consume) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Echo DSL β”‚ Dynamic prompt templating β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ PLP Protocol β”‚ ◀── You are here β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ PLP Registry β”‚ Directory of PLP servers β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +| Component | Description | Link | +|-----------|-------------|------| +| **Echostash** | Prompt management platform (PLP-compliant) | [echostash.app](https://echostash.app) | +| **EchoIDE** | Web IDE for prompt development | [ide.echostash.app](https://ide.echostash.app) | +| **Echo PDK** | Dynamic prompt templating DSL | [GitHub](https://github.com/gorealai/echo-pdk) | +| **PLP Registry** | Directory of PLP servers | [plp.pub](https://plp.pub) | + +--- + ## 🀝 Community - **GitHub Discussions** – Ask questions, share implementations diff --git a/sdks/js/src/index.test.ts b/sdks/js/src/index.test.ts index 7b58596..19232cf 100644 --- a/sdks/js/src/index.test.ts +++ b/sdks/js/src/index.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { PLPClient, PLPError } from './index'; +import { + PLPClient, + PLPError, + isMultiModal, + normalizeContent, + getTextContent, + type ContentPart, + type PromptEnvelope, +} from './index'; // Mock fetch global.fetch = vi.fn(); @@ -156,4 +164,140 @@ describe('PLPClient', () => { ); }); }); + + describe('multi-modal content', () => { + it('should handle multi-modal prompt in get()', async () => { + const mockPrompt: PromptEnvelope = { + id: 'vision/test', + content: [ + { type: 'text', text: 'Analyze this image:' }, + { type: 'image_url', image_url: { url: 'https://example.com/img.png', detail: 'high' } }, + ], + meta: { version: '1.0.0' }, + }; + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockPrompt, + }); + + const result = await client.get('vision/test'); + + expect(result.content).toEqual(mockPrompt.content); + expect(Array.isArray(result.content)).toBe(true); + }); + + it('should handle multi-modal prompt in put()', async () => { + const input = { + content: [ + { type: 'text' as const, text: 'Describe this:' }, + { type: 'image_url' as const, image_url: { url: 'https://example.com/img.png' } }, + ], + meta: { version: '1.0.0' }, + }; + + const mockResponse = { + id: 'vision/new', + ...input, + }; + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => mockResponse, + }); + + const result = await client.put('vision/new', input); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify(input), + }) + ); + expect(result.content).toEqual(input.content); + }); + }); +}); + +describe('Helper Functions', () => { + describe('isMultiModal()', () => { + it('should return false for string content', () => { + expect(isMultiModal('Hello {{name}}')).toBe(false); + }); + + it('should return false for text-only array content', () => { + const content: ContentPart[] = [ + { type: 'text', text: 'First part' }, + { type: 'text', text: 'Second part' }, + ]; + expect(isMultiModal(content)).toBe(false); + }); + + it('should return true for content with images', () => { + const content: ContentPart[] = [ + { type: 'text', text: 'Look at this:' }, + { type: 'image_url', image_url: { url: 'https://example.com/img.png' } }, + ]; + expect(isMultiModal(content)).toBe(true); + }); + + it('should work with PromptEnvelope', () => { + const textPrompt: PromptEnvelope = { + id: 'test', + content: 'Simple text', + meta: {}, + }; + expect(isMultiModal(textPrompt)).toBe(false); + + const multiModalPrompt: PromptEnvelope = { + id: 'test', + content: [ + { type: 'text', text: 'With image' }, + { type: 'image_url', image_url: { url: 'https://example.com/img.png' } }, + ], + meta: {}, + }; + expect(isMultiModal(multiModalPrompt)).toBe(true); + }); + }); + + describe('normalizeContent()', () => { + it('should wrap string in TextContent array', () => { + const result = normalizeContent('Hello {{name}}'); + expect(result).toEqual([{ type: 'text', text: 'Hello {{name}}' }]); + }); + + it('should return array content as-is', () => { + const content: ContentPart[] = [ + { type: 'text', text: 'Part 1' }, + { type: 'image_url', image_url: { url: 'https://example.com/img.png' } }, + ]; + const result = normalizeContent(content); + expect(result).toBe(content); // Same reference + }); + }); + + describe('getTextContent()', () => { + it('should return string content as-is', () => { + expect(getTextContent('Hello {{name}}')).toBe('Hello {{name}}'); + }); + + it('should extract and join text from ContentPart array', () => { + const content: ContentPart[] = [ + { type: 'text', text: 'First' }, + { type: 'image_url', image_url: { url: 'https://example.com/img.png' } }, + { type: 'text', text: 'Second' }, + ]; + expect(getTextContent(content)).toBe('First\nSecond'); + }); + + it('should handle array with no text parts', () => { + const content: ContentPart[] = [ + { type: 'image_url', image_url: { url: 'https://example.com/img.png' } }, + ]; + expect(getTextContent(content)).toBe(''); + }); + }); }); diff --git a/sdks/js/src/index.ts b/sdks/js/src/index.ts index d9712fa..f3c40df 100644 --- a/sdks/js/src/index.ts +++ b/sdks/js/src/index.ts @@ -3,9 +3,40 @@ * @license MIT */ +// ============================================================================= +// Multi-modal Content Types +// ============================================================================= + +export interface TextContent { + type: 'text'; + text: string; +} + +export interface ImageUrl { + url: string; + detail?: 'auto' | 'low' | 'high'; +} + +export interface ImageContent { + type: 'image_url'; + image_url: ImageUrl; +} + +export type ContentPart = TextContent | ImageContent; + +/** + * Prompt content - either a simple string (backwards compatible) + * or an array of content parts for multi-modal prompts. + */ +export type PromptContent = string | ContentPart[]; + +// ============================================================================= +// Prompt Envelope Types +// ============================================================================= + export interface PromptEnvelope { id: string; - content: string; + content: PromptContent; meta: { version?: string; author?: string; @@ -16,10 +47,14 @@ export interface PromptEnvelope { } export interface PromptInput { - content: string; + content: PromptContent; meta?: Record; } +// ============================================================================= +// Client Configuration +// ============================================================================= + export interface PLPClientOptions { apiKey?: string; headers?: Record; @@ -37,6 +72,59 @@ export class PLPError extends Error { } } +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Check if a prompt has multi-modal content (images) + * @param prompt - The prompt envelope or content + * @returns true if content contains image parts + */ +export function isMultiModal(prompt: PromptEnvelope | PromptContent): boolean { + const content = typeof prompt === 'object' && prompt !== null && 'content' in prompt + ? (prompt as PromptEnvelope).content + : prompt; + + if (typeof content === 'string') { + return false; + } + + return content.some((part) => part.type === 'image_url'); +} + +/** + * Normalize content to ContentPart[] format + * @param content - String or ContentPart array + * @returns ContentPart[] (string is wrapped as single TextContent) + */ +export function normalizeContent(content: PromptContent): ContentPart[] { + if (typeof content === 'string') { + return [{ type: 'text', text: content }]; + } + return content; +} + +/** + * Get text-only content from a prompt (useful for token counting) + * @param content - String or ContentPart array + * @returns Combined text from all text parts + */ +export function getTextContent(content: PromptContent): string { + if (typeof content === 'string') { + return content; + } + + return content + .filter((part): part is TextContent => part.type === 'text') + .map((part) => part.text) + .join('\n'); +} + +// ============================================================================= +// PLP Client +// ============================================================================= + export class PLPClient { private baseUrl: string; private apiKey?: string; diff --git a/sdks/python/src/plp_client/__init__.py b/sdks/python/src/plp_client/__init__.py index d64af7b..db8f468 100644 --- a/sdks/python/src/plp_client/__init__.py +++ b/sdks/python/src/plp_client/__init__.py @@ -2,7 +2,37 @@ plp-client - Official Python client for PLP (Prompt Library Protocol) """ -from .client import PLPClient, PLPError, PromptEnvelope, PromptInput +from .client import ( + PLPClient, + PLPError, + PromptEnvelope, + PromptInput, + # Multi-modal types + TextContent, + ImageUrl, + ImageContent, + ContentPart, + PromptContent, + # Helper functions + is_multi_modal, + normalize_content, + get_text_content, +) __version__ = "1.0.0" -__all__ = ["PLPClient", "PLPError", "PromptEnvelope", "PromptInput"] +__all__ = [ + "PLPClient", + "PLPError", + "PromptEnvelope", + "PromptInput", + # Multi-modal types + "TextContent", + "ImageUrl", + "ImageContent", + "ContentPart", + "PromptContent", + # Helper functions + "is_multi_modal", + "normalize_content", + "get_text_content", +] diff --git a/sdks/python/src/plp_client/client.py b/sdks/python/src/plp_client/client.py index 3ec5cdc..4ea566a 100644 --- a/sdks/python/src/plp_client/client.py +++ b/sdks/python/src/plp_client/client.py @@ -2,14 +2,170 @@ PLP Client Implementation """ -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Union +from dataclasses import dataclass import requests +# ============================================================================= +# Multi-modal Content Types +# ============================================================================= + + +@dataclass +class TextContent: + """Text content part.""" + + type: str # Always "text" + text: str + + def __init__(self, text: str) -> None: + self.type = "text" + self.text = text + + def to_dict(self) -> Dict[str, Any]: + return {"type": self.type, "text": self.text} + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TextContent": + return cls(text=data["text"]) + + +@dataclass +class ImageUrl: + """Image URL with optional detail level.""" + + url: str + detail: Optional[str] = None # "auto" | "low" | "high" + + def to_dict(self) -> Dict[str, Any]: + result: Dict[str, Any] = {"url": self.url} + if self.detail: + result["detail"] = self.detail + return result + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ImageUrl": + return cls(url=data["url"], detail=data.get("detail")) + + +@dataclass +class ImageContent: + """Image content part.""" + + type: str # Always "image_url" + image_url: ImageUrl + + def __init__(self, image_url: ImageUrl) -> None: + self.type = "image_url" + self.image_url = image_url + + def to_dict(self) -> Dict[str, Any]: + return {"type": self.type, "image_url": self.image_url.to_dict()} + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ImageContent": + return cls(image_url=ImageUrl.from_dict(data["image_url"])) + + +# Type aliases +ContentPart = Union[TextContent, ImageContent] +PromptContent = Union[str, List[ContentPart]] + + +def content_part_from_dict(data: Dict[str, Any]) -> ContentPart: + """Create a ContentPart from a dictionary.""" + if data["type"] == "text": + return TextContent.from_dict(data) + elif data["type"] == "image_url": + return ImageContent.from_dict(data) + else: + raise ValueError(f"Unknown content part type: {data['type']}") + + +def content_from_dict(data: Union[str, List[Dict[str, Any]]]) -> PromptContent: + """Create PromptContent from a dictionary or string.""" + if isinstance(data, str): + return data + return [content_part_from_dict(part) for part in data] + + +def content_to_dict(content: PromptContent) -> Union[str, List[Dict[str, Any]]]: + """Convert PromptContent to dictionary or string.""" + if isinstance(content, str): + return content + return [part.to_dict() for part in content] + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def is_multi_modal(prompt: Union["PromptEnvelope", PromptContent]) -> bool: + """ + Check if a prompt has multi-modal content (images). + + Args: + prompt: The prompt envelope or content + + Returns: + True if content contains image parts + """ + if isinstance(prompt, PromptEnvelope): + content = prompt.content + else: + content = prompt + + if isinstance(content, str): + return False + + return any(isinstance(part, ImageContent) or part.type == "image_url" for part in content) + + +def normalize_content(content: PromptContent) -> List[ContentPart]: + """ + Normalize content to ContentPart[] format. + + Args: + content: String or ContentPart array + + Returns: + ContentPart[] (string is wrapped as single TextContent) + """ + if isinstance(content, str): + return [TextContent(text=content)] + return content + + +def get_text_content(content: PromptContent) -> str: + """ + Get text-only content from a prompt (useful for token counting). + + Args: + content: String or ContentPart array + + Returns: + Combined text from all text parts + """ + if isinstance(content, str): + return content + + text_parts = [ + part.text for part in content if isinstance(part, TextContent) or part.type == "text" + ] + return "\n".join(text_parts) + + +# ============================================================================= +# Prompt Types +# ============================================================================= + + class PromptEnvelope: """Represents a PLP prompt envelope.""" - def __init__(self, id: str, content: str, meta: Dict[str, Any]) -> None: + def __init__(self, id: str, content: PromptContent, meta: Dict[str, Any]) -> None: self.id = id self.content = content self.meta = meta @@ -19,7 +175,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "PromptEnvelope": """Create a PromptEnvelope from a dictionary.""" return cls( id=data["id"], - content=data["content"], + content=content_from_dict(data["content"]), meta=data.get("meta", {}), ) @@ -27,7 +183,7 @@ def to_dict(self) -> Dict[str, Any]: """Convert to dictionary.""" return { "id": self.id, - "content": self.content, + "content": content_to_dict(self.content), "meta": self.meta, } @@ -38,14 +194,14 @@ def __repr__(self) -> str: class PromptInput: """Input for creating/updating a prompt.""" - def __init__(self, content: str, meta: Optional[Dict[str, Any]] = None) -> None: + def __init__(self, content: PromptContent, meta: Optional[Dict[str, Any]] = None) -> None: self.content = content self.meta = meta or {} def to_dict(self) -> Dict[str, Any]: """Convert to dictionary.""" return { - "content": self.content, + "content": content_to_dict(self.content), "meta": self.meta, } @@ -64,6 +220,11 @@ def __init__( self.response = response +# ============================================================================= +# PLP Client +# ============================================================================= + + class PLPClient: """ Official Python client for PLP (Prompt Library Protocol). diff --git a/sdks/python/tests/test_client.py b/sdks/python/tests/test_client.py index 9fc16ff..2a735c9 100644 --- a/sdks/python/tests/test_client.py +++ b/sdks/python/tests/test_client.py @@ -4,7 +4,18 @@ import pytest from unittest.mock import Mock, patch -from plp_client import PLPClient, PLPError, PromptInput +from plp_client import ( + PLPClient, + PLPError, + PromptInput, + PromptEnvelope, + TextContent, + ImageUrl, + ImageContent, + is_multi_modal, + normalize_content, + get_text_content, +) @pytest.fixture @@ -169,3 +180,252 @@ def test_prompt_input_to_dict(): assert result["content"] == "Test content" assert result["meta"]["version"] == "1.0.0" assert result["meta"]["author"] == "test" + + +# ============================================================================= +# Multi-modal Content Tests +# ============================================================================= + + +class TestMultiModalContent: + """Tests for multi-modal content support.""" + + @patch("plp_client.client.requests.Session.request") + def test_get_multi_modal_prompt(self, mock_request, client): + """Test getting a multi-modal prompt.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "vision/test", + "content": [ + {"type": "text", "text": "Analyze this image:"}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/img.png", + "detail": "high", + }, + }, + ], + "meta": {"version": "1.0.0"}, + } + mock_request.return_value = mock_response + + prompt = client.get("vision/test") + + assert prompt.id == "vision/test" + assert isinstance(prompt.content, list) + assert len(prompt.content) == 2 + assert isinstance(prompt.content[0], TextContent) + assert prompt.content[0].text == "Analyze this image:" + assert isinstance(prompt.content[1], ImageContent) + assert prompt.content[1].image_url.url == "https://example.com/img.png" + assert prompt.content[1].image_url.detail == "high" + + @patch("plp_client.client.requests.Session.request") + def test_put_multi_modal_prompt(self, mock_request, client): + """Test creating a multi-modal prompt.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 201 + mock_response.json.return_value = { + "id": "vision/new", + "content": [ + {"type": "text", "text": "Describe this:"}, + {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}, + ], + "meta": {"version": "1.0.0"}, + } + mock_request.return_value = mock_response + + input_data = PromptInput( + content=[ + TextContent(text="Describe this:"), + ImageContent(image_url=ImageUrl(url="https://example.com/img.png")), + ], + meta={"version": "1.0.0"}, + ) + prompt = client.put("vision/new", input_data) + + assert prompt.id == "vision/new" + assert isinstance(prompt.content, list) + mock_request.assert_called_once() + + # Verify the request body + call_args = mock_request.call_args + body = call_args[1]["json"] + assert isinstance(body["content"], list) + assert body["content"][0]["type"] == "text" + assert body["content"][1]["type"] == "image_url" + + +class TestHelperFunctions: + """Tests for helper functions.""" + + def test_is_multi_modal_string_content(self): + """Test is_multi_modal returns false for string content.""" + assert is_multi_modal("Hello {{name}}") is False + + def test_is_multi_modal_text_only_array(self): + """Test is_multi_modal returns false for text-only array content.""" + content = [ + TextContent(text="First part"), + TextContent(text="Second part"), + ] + assert is_multi_modal(content) is False + + def test_is_multi_modal_with_images(self): + """Test is_multi_modal returns true for content with images.""" + content = [ + TextContent(text="Look at this:"), + ImageContent(image_url=ImageUrl(url="https://example.com/img.png")), + ] + assert is_multi_modal(content) is True + + def test_is_multi_modal_with_prompt_envelope(self): + """Test is_multi_modal works with PromptEnvelope.""" + text_prompt = PromptEnvelope( + id="test", + content="Simple text", + meta={}, + ) + assert is_multi_modal(text_prompt) is False + + multi_modal_prompt = PromptEnvelope( + id="test", + content=[ + TextContent(text="With image"), + ImageContent(image_url=ImageUrl(url="https://example.com/img.png")), + ], + meta={}, + ) + assert is_multi_modal(multi_modal_prompt) is True + + def test_normalize_content_string(self): + """Test normalize_content wraps string in TextContent array.""" + result = normalize_content("Hello {{name}}") + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "Hello {{name}}" + + def test_normalize_content_array(self): + """Test normalize_content returns array content as-is.""" + content = [ + TextContent(text="Part 1"), + ImageContent(image_url=ImageUrl(url="https://example.com/img.png")), + ] + result = normalize_content(content) + assert result is content # Same reference + + def test_get_text_content_string(self): + """Test get_text_content returns string content as-is.""" + assert get_text_content("Hello {{name}}") == "Hello {{name}}" + + def test_get_text_content_array(self): + """Test get_text_content extracts and joins text from ContentPart array.""" + content = [ + TextContent(text="First"), + ImageContent(image_url=ImageUrl(url="https://example.com/img.png")), + TextContent(text="Second"), + ] + assert get_text_content(content) == "First\nSecond" + + def test_get_text_content_no_text_parts(self): + """Test get_text_content handles array with no text parts.""" + content = [ + ImageContent(image_url=ImageUrl(url="https://example.com/img.png")), + ] + assert get_text_content(content) == "" + + +class TestContentTypes: + """Tests for multi-modal content types.""" + + def test_text_content_to_dict(self): + """Test TextContent serialization.""" + content = TextContent(text="Hello") + result = content.to_dict() + assert result == {"type": "text", "text": "Hello"} + + def test_text_content_from_dict(self): + """Test TextContent deserialization.""" + content = TextContent.from_dict({"type": "text", "text": "Hello"}) + assert content.type == "text" + assert content.text == "Hello" + + def test_image_url_to_dict(self): + """Test ImageUrl serialization.""" + url = ImageUrl(url="https://example.com/img.png", detail="high") + result = url.to_dict() + assert result == {"url": "https://example.com/img.png", "detail": "high"} + + def test_image_url_to_dict_no_detail(self): + """Test ImageUrl serialization without detail.""" + url = ImageUrl(url="https://example.com/img.png") + result = url.to_dict() + assert result == {"url": "https://example.com/img.png"} + + def test_image_content_to_dict(self): + """Test ImageContent serialization.""" + content = ImageContent(image_url=ImageUrl(url="https://example.com/img.png")) + result = content.to_dict() + assert result == { + "type": "image_url", + "image_url": {"url": "https://example.com/img.png"}, + } + + def test_image_content_from_dict(self): + """Test ImageContent deserialization.""" + content = ImageContent.from_dict( + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.png", "detail": "low"}, + } + ) + assert content.type == "image_url" + assert content.image_url.url == "https://example.com/img.png" + assert content.image_url.detail == "low" + + def test_prompt_envelope_multi_modal_to_dict(self): + """Test PromptEnvelope with multi-modal content to_dict.""" + envelope = PromptEnvelope( + id="test/prompt", + content=[ + TextContent(text="Hello"), + ImageContent(image_url=ImageUrl(url="https://example.com/img.png")), + ], + meta={"version": "1.0.0"}, + ) + result = envelope.to_dict() + assert result == { + "id": "test/prompt", + "content": [ + {"type": "text", "text": "Hello"}, + {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}, + ], + "meta": {"version": "1.0.0"}, + } + + def test_prompt_input_multi_modal_to_dict(self): + """Test PromptInput with multi-modal content to_dict.""" + input_data = PromptInput( + content=[ + TextContent(text="Describe:"), + ImageContent( + image_url=ImageUrl(url="https://example.com/img.png", detail="auto") + ), + ], + meta={"version": "1.0.0"}, + ) + result = input_data.to_dict() + assert result == { + "content": [ + {"type": "text", "text": "Describe:"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.png", "detail": "auto"}, + }, + ], + "meta": {"version": "1.0.0"}, + } diff --git a/spec/openapi.yaml b/spec/openapi.yaml index f81c5f9..ab695ea 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -83,6 +83,7 @@ paths: $ref: '#/components/schemas/PromptEnvelope' examples: welcomeEmail: + summary: Simple text prompt value: id: marketing/welcome-email content: "Hello {{name}}, welcome to {{product}}!" @@ -90,6 +91,23 @@ paths: version: "1.2.0" author: shimon description: Sent on user signup + multiModal: + summary: Multi-modal prompt with image + value: + id: vision/product-analyzer + content: + - type: text + text: "Analyze this product image for {{brand}}:" + - type: image_url + image_url: + url: "https://example.com/product.jpg" + detail: high + - type: text + text: "Focus on: {{aspects}}" + meta: + version: "1.0.0" + author: yoad + description: Product image analysis prompt '404': description: Prompt not found content: @@ -131,17 +149,32 @@ paths: $ref: '#/components/schemas/PromptInput' examples: createPrompt: + summary: Simple text prompt value: content: "You are a helpful FAQ bot. Answer: {{question}}" meta: version: "1.0.0" author: yoad updatePrompt: + summary: Update existing prompt value: content: "Updated prompt content with {{variable}}" meta: version: "2.0.0" description: New iteration + createMultiModal: + summary: Multi-modal prompt with image + value: + content: + - type: text + text: "Describe what you see in this image:" + - type: image_url + image_url: + url: "https://example.com/image.png" + detail: auto + meta: + version: "1.0.0" + author: yoad responses: '201': description: Successfully created new prompt @@ -268,6 +301,68 @@ components: description: Optional API key for authentication (Bearer token) schemas: + # Multi-modal Content Types + TextContent: + type: object + required: + - type + - text + properties: + type: + type: string + enum: [text] + description: Content type identifier + text: + type: string + description: The text content (can include variables like {{name}}) + example: "Hello {{name}}, welcome to {{product}}!" + + ImageUrl: + type: object + required: + - url + properties: + url: + type: string + description: Image URL or base64 data URI + example: "https://example.com/image.png" + detail: + type: string + enum: [auto, low, high] + description: Image detail level for vision models + default: auto + + ImageContent: + type: object + required: + - type + - image_url + properties: + type: + type: string + enum: [image_url] + description: Content type identifier + image_url: + $ref: '#/components/schemas/ImageUrl' + + ContentPart: + oneOf: + - $ref: '#/components/schemas/TextContent' + - $ref: '#/components/schemas/ImageContent' + description: A single content part (text or image) + + PromptContent: + oneOf: + - type: string + description: Simple string content (backwards compatible) + - type: array + items: + $ref: '#/components/schemas/ContentPart' + description: Multi-modal content array + description: | + Prompt content - either a simple string (backwards compatible) + or an array of content parts for multi-modal prompts. + PromptEnvelope: type: object required: @@ -281,9 +376,7 @@ components: example: marketing/welcome-email pattern: '^[a-zA-Z0-9/_-]+$' content: - type: string - description: The raw prompt template (can include variables like {{name}}) - example: "Hello {{name}}, welcome to {{product}}!" + $ref: '#/components/schemas/PromptContent' meta: type: object description: Metadata container (must exist, can be empty) @@ -316,9 +409,7 @@ components: - meta properties: content: - type: string - description: The raw prompt template - example: "You are a helpful assistant. Task: {{task}}" + $ref: '#/components/schemas/PromptContent' meta: type: object description: Metadata container (can be empty object {}) diff --git a/spec/plp-schema.json b/spec/plp-schema.json index 1b05dac..ddfb6b9 100644 --- a/spec/plp-schema.json +++ b/spec/plp-schema.json @@ -5,6 +5,63 @@ "description": "JSON Schema for validating PLP (Prompt Library Protocol) prompt objects", "type": "object", "required": ["id", "content", "meta"], + "definitions": { + "TextContent": { + "type": "object", + "required": ["type", "text"], + "properties": { + "type": { + "type": "string", + "enum": ["text"], + "description": "Content type identifier" + }, + "text": { + "type": "string", + "description": "The text content (can include variables like {{name}})" + } + }, + "additionalProperties": false + }, + "ImageUrl": { + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "description": "Image URL or base64 data URI" + }, + "detail": { + "type": "string", + "enum": ["auto", "low", "high"], + "description": "Image detail level for vision models", + "default": "auto" + } + }, + "additionalProperties": false + }, + "ImageContent": { + "type": "object", + "required": ["type", "image_url"], + "properties": { + "type": { + "type": "string", + "enum": ["image_url"], + "description": "Content type identifier" + }, + "image_url": { + "$ref": "#/definitions/ImageUrl" + } + }, + "additionalProperties": false + }, + "ContentPart": { + "oneOf": [ + { "$ref": "#/definitions/TextContent" }, + { "$ref": "#/definitions/ImageContent" } + ], + "description": "A single content part (text or image)" + } + }, "properties": { "id": { "type": "string", @@ -22,12 +79,21 @@ ] }, "content": { - "type": "string", - "description": "The raw prompt template (can include variables like {{name}})", - "examples": [ - "Hello {{name}}, welcome to {{product}}!", - "You are a helpful assistant. Task: {{task}}" - ] + "oneOf": [ + { + "type": "string", + "description": "Simple string content (backwards compatible)" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/ContentPart" + }, + "minItems": 1, + "description": "Multi-modal content array" + } + ], + "description": "Prompt content - either a simple string or an array of content parts" }, "meta": { "type": "object", @@ -139,6 +205,31 @@ "id": "simple/prompt", "content": "Just a simple prompt", "meta": {} + }, + { + "id": "vision/product-analyzer", + "content": [ + { + "type": "text", + "text": "Analyze this product image for {{brand}}:" + }, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/product.jpg", + "detail": "high" + } + }, + { + "type": "text", + "text": "Focus on: {{aspects}}" + } + ], + "meta": { + "version": "1.0.0", + "author": "yoad", + "description": "Multi-modal product analysis prompt" + } } ] } From 4d954f158316a9a0a6a4a9dad92dc16bd3ffb29d Mon Sep 17 00:00:00 2001 From: yoadelkayam Date: Tue, 27 Jan 2026 01:13:55 +0200 Subject: [PATCH 2/5] chore: remove Echostash ecosystem section from README Keep README focused on PLP protocol only. Co-Authored-By: Claude Opus 4.5 --- README.md | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/README.md b/README.md index a90c62a..e82273e 100644 --- a/README.md +++ b/README.md @@ -204,44 +204,6 @@ Want to build a PLP-compliant server or client? --- -## 🌐 Echostash Ecosystem - -PLP is part of the **Echostash ecosystem** - a complete toolkit for prompt engineering: - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ ECHOSTASH ECOSYSTEM β”‚ -β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ EchoIDE β”‚ β”‚ Echostash β”‚ β”‚ Your App β”‚ β”‚ -β”‚ β”‚ (Develop) │────▢│ (Manage) │◀────│ (Consume) β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Echo DSL β”‚ Dynamic prompt templating β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ PLP Protocol β”‚ ◀── You are here β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ PLP Registry β”‚ Directory of PLP servers β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -| Component | Description | Link | -|-----------|-------------|------| -| **Echostash** | Prompt management platform (PLP-compliant) | [echostash.app](https://echostash.app) | -| **EchoIDE** | Web IDE for prompt development | [ide.echostash.app](https://ide.echostash.app) | -| **Echo PDK** | Dynamic prompt templating DSL | [GitHub](https://github.com/gorealai/echo-pdk) | -| **PLP Registry** | Directory of PLP servers | [plp.pub](https://plp.pub) | - ---- - ## 🀝 Community - **GitHub Discussions** – Ask questions, share implementations From eee7149ec12886e212406e1e63b91982fc40ce78 Mon Sep 17 00:00:00 2001 From: yoadelkayam Date: Tue, 27 Jan 2026 22:38:11 +0200 Subject: [PATCH 3/5] ci: add release workflow for SDK publishing - Publish JS SDK to npm on js-v* tags - Publish Python SDK to PyPI on py-v* tags - Runs tests before publishing Co-Authored-By: Claude Opus 4.5 --- .github/workflows/release.yml | 52 +++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f0b9047 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release SDKs + +on: + release: + types: [published] + +jobs: + publish-js: + name: Publish JavaScript SDK + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/js-v') + defaults: + run: + working-directory: sdks/js + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + - run: npm test + - run: npm run build + - run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-python: + name: Publish Python SDK + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/py-v') + defaults: + run: + working-directory: sdks/python + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install build tools + run: pip install build twine + - name: Run tests + run: | + pip install -e ".[dev]" + pytest + - name: Build package + run: python -m build + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI }} + run: twine upload dist/* From 456ec6dbce24e7629bf14fbaa7e37f49fe371664 Mon Sep 17 00:00:00 2001 From: yoadelkayam Date: Tue, 27 Jan 2026 22:49:50 +0200 Subject: [PATCH 4/5] style: format Python SDK with black Co-Authored-By: Claude Opus 4.5 --- sdks/python/src/plp_client/client.py | 13 +++++++++---- sdks/python/tests/test_client.py | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/sdks/python/src/plp_client/client.py b/sdks/python/src/plp_client/client.py index 4ea566a..3d3f678 100644 --- a/sdks/python/src/plp_client/client.py +++ b/sdks/python/src/plp_client/client.py @@ -6,7 +6,6 @@ from dataclasses import dataclass import requests - # ============================================================================= # Multi-modal Content Types # ============================================================================= @@ -120,7 +119,9 @@ def is_multi_modal(prompt: Union["PromptEnvelope", PromptContent]) -> bool: if isinstance(content, str): return False - return any(isinstance(part, ImageContent) or part.type == "image_url" for part in content) + return any( + isinstance(part, ImageContent) or part.type == "image_url" for part in content + ) def normalize_content(content: PromptContent) -> List[ContentPart]: @@ -152,7 +153,9 @@ def get_text_content(content: PromptContent) -> str: return content text_parts = [ - part.text for part in content if isinstance(part, TextContent) or part.type == "text" + part.text + for part in content + if isinstance(part, TextContent) or part.type == "text" ] return "\n".join(text_parts) @@ -194,7 +197,9 @@ def __repr__(self) -> str: class PromptInput: """Input for creating/updating a prompt.""" - def __init__(self, content: PromptContent, meta: Optional[Dict[str, Any]] = None) -> None: + def __init__( + self, content: PromptContent, meta: Optional[Dict[str, Any]] = None + ) -> None: self.content = content self.meta = meta or {} diff --git a/sdks/python/tests/test_client.py b/sdks/python/tests/test_client.py index 2a735c9..94d53e8 100644 --- a/sdks/python/tests/test_client.py +++ b/sdks/python/tests/test_client.py @@ -233,7 +233,10 @@ def test_put_multi_modal_prompt(self, mock_request, client): "id": "vision/new", "content": [ {"type": "text", "text": "Describe this:"}, - {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.png"}, + }, ], "meta": {"version": "1.0.0"}, } @@ -402,7 +405,10 @@ def test_prompt_envelope_multi_modal_to_dict(self): "id": "test/prompt", "content": [ {"type": "text", "text": "Hello"}, - {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.png"}, + }, ], "meta": {"version": "1.0.0"}, } @@ -424,7 +430,10 @@ def test_prompt_input_multi_modal_to_dict(self): {"type": "text", "text": "Describe:"}, { "type": "image_url", - "image_url": {"url": "https://example.com/img.png", "detail": "auto"}, + "image_url": { + "url": "https://example.com/img.png", + "detail": "auto", + }, }, ], "meta": {"version": "1.0.0"}, From b35cb0a6353da0dcde2c909d42bb81231eeabeeb Mon Sep 17 00:00:00 2001 From: yoadelkayam Date: Tue, 27 Jan 2026 22:53:10 +0200 Subject: [PATCH 5/5] fix: Python SDK mypy errors and update to Python 3.9+ - Update minimum Python version to 3.9 (mypy dropped 3.8 support) - Add types-requests to dev dependencies - Fix type narrowing in is_multi_modal and get_text_content - Update CI matrix to remove Python 3.8 Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 2 +- sdks/python/pyproject.toml | 6 +++--- sdks/python/src/plp_client/client.py | 10 ++-------- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1da8db..3ac7bd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: working-directory: sdks/python strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index e890bc8..db431d4 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -7,7 +7,7 @@ name = "plp-client" version = "1.0.0" description = "Official Python client for PLP (Prompt Library Protocol)" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" license = "MIT" keywords = ["plp", "prompt", "library", "protocol", "ai", "llm", "openai", "anthropic"] authors = [ @@ -18,7 +18,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -36,6 +35,7 @@ dev = [ "black>=23.0.0", "mypy>=1.0.0", "ruff>=0.1.0", + "types-requests>=2.28.0", ] [project.urls] @@ -55,7 +55,7 @@ line-length = 88 target-version = "py38" [tool.mypy] -python_version = "3.8" +python_version = "3.9" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true diff --git a/sdks/python/src/plp_client/client.py b/sdks/python/src/plp_client/client.py index 3d3f678..b9ea01a 100644 --- a/sdks/python/src/plp_client/client.py +++ b/sdks/python/src/plp_client/client.py @@ -119,9 +119,7 @@ def is_multi_modal(prompt: Union["PromptEnvelope", PromptContent]) -> bool: if isinstance(content, str): return False - return any( - isinstance(part, ImageContent) or part.type == "image_url" for part in content - ) + return any(isinstance(part, ImageContent) for part in content) def normalize_content(content: PromptContent) -> List[ContentPart]: @@ -152,11 +150,7 @@ def get_text_content(content: PromptContent) -> str: if isinstance(content, str): return content - text_parts = [ - part.text - for part in content - if isinstance(part, TextContent) or part.type == "text" - ] + text_parts = [part.text for part in content if isinstance(part, TextContent)] return "\n".join(text_parts)