Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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/*
146 changes: 145 additions & 1 deletion sdks/js/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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('');
});
});
});
92 changes: 90 additions & 2 deletions sdks/js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,10 +47,14 @@ export interface PromptEnvelope {
}

export interface PromptInput {
content: string;
content: PromptContent;
meta?: Record<string, unknown>;
}

// =============================================================================
// Client Configuration
// =============================================================================

export interface PLPClientOptions {
apiKey?: string;
headers?: Record<string, string>;
Expand All @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions sdks/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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",
Expand All @@ -36,6 +35,7 @@ dev = [
"black>=23.0.0",
"mypy>=1.0.0",
"ruff>=0.1.0",
"types-requests>=2.28.0",
]

[project.urls]
Expand All @@ -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
Expand Down
Loading