From ecf628aedfd85695fe2fbbc487faeb65855b1412 Mon Sep 17 00:00:00 2001 From: yoadelkayam Date: Tue, 27 Jan 2026 23:07:14 +0200 Subject: [PATCH] feat: add multi-modal content support to middleware and registry Express Middleware: - Add ContentPart types (TextContent, ImageContent) - Update PromptEnvelope to accept string | ContentPart[] - Add isValidContent validation function - Backward compatible with string content Registry: - Add multi-modal types to types.ts - Update compliance tests to accept string or array content - Bump version to 1.0.0 Release workflow: - Add middleware-v* tag for Express middleware publish - Add registry-v* tag for Registry CLI publish Co-Authored-By: Claude Opus 4.5 --- .github/workflows/release.yml | 39 +++++++++++ .../express-middleware/src/index.ts | 67 ++++++++++++++++++- tools/registry/package.json | 2 +- tools/registry/src/tests/index.ts | 40 ++++++++++- tools/registry/src/types.ts | 21 +++++- 5 files changed, 161 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0b9047..01b6692 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,3 +50,42 @@ jobs: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI }} run: twine upload dist/* + + publish-express-middleware: + name: Publish Express Middleware + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/middleware-v') + defaults: + run: + working-directory: implementations/express-middleware + 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-registry: + name: Publish Registry CLI + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/registry-v') + defaults: + run: + working-directory: tools/registry + 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 run build + - run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/implementations/express-middleware/src/index.ts b/implementations/express-middleware/src/index.ts index 208e77d..c37fffc 100644 --- a/implementations/express-middleware/src/index.ts +++ b/implementations/express-middleware/src/index.ts @@ -7,9 +7,35 @@ import { Router, Request, Response, NextFunction } from 'express'; import * as fs from 'fs/promises'; import * as path from 'path'; +// ============================================================================= +// 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; +export type PromptContent = string | ContentPart[]; + +// ============================================================================= +// Prompt Types +// ============================================================================= + export interface PromptEnvelope { id: string; - content: string; + content: PromptContent; meta: Record; } @@ -170,6 +196,40 @@ function authMiddleware( }; } +/** + * Validate content part structure + */ +function isValidContentPart(part: unknown): part is ContentPart { + if (!part || typeof part !== 'object') return false; + const obj = part as Record; + + if (obj.type === 'text') { + return typeof obj.text === 'string'; + } + + if (obj.type === 'image_url') { + const imageUrl = obj.image_url as Record | undefined; + return imageUrl !== null && typeof imageUrl === 'object' && typeof imageUrl.url === 'string'; + } + + return false; +} + +/** + * Validate content: string or array of ContentParts + */ +function isValidContent(content: unknown): content is PromptContent { + if (typeof content === 'string') { + return true; + } + + if (Array.isArray(content)) { + return content.length > 0 && content.every(isValidContentPart); + } + + return false; +} + /** * Validate prompt ID * - Must contain only alphanumeric, /, _, - @@ -285,7 +345,8 @@ export function plpMiddleware(options: PLPMiddlewareOptions): Router { const { content, meta } = req.body as { content?: unknown; meta?: unknown }; - if (typeof content !== 'string') { + // Validate content: must be string or array of ContentParts + if (!isValidContent(content)) { return res .status(400) .json({ error: 'Invalid request - content field is required' }); @@ -337,4 +398,4 @@ export function plpMiddleware(options: PLPMiddlewareOptions): Router { } // Export types and utilities -export { FileStorage, validatePromptId, parsePromptPath, SEMVER_PATTERN }; +export { FileStorage, validatePromptId, parsePromptPath, SEMVER_PATTERN, isValidContent, isValidContentPart }; diff --git a/tools/registry/package.json b/tools/registry/package.json index 19db61f..2a9c788 100644 --- a/tools/registry/package.json +++ b/tools/registry/package.json @@ -1,6 +1,6 @@ { "name": "@goreal-ai/plp-registry", - "version": "0.0.3", + "version": "1.0.0", "description": "PLP Registry CLI - Manage PLP server registration, compliance testing, and listing on plp.pub", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/tools/registry/src/tests/index.ts b/tools/registry/src/tests/index.ts index a719aef..a718787 100644 --- a/tools/registry/src/tests/index.ts +++ b/tools/registry/src/tests/index.ts @@ -104,6 +104,40 @@ function buildHeaders(ctx: TestContext, contentType = false): Record; + + if (obj.type === 'text') { + return typeof obj.text === 'string'; + } + + if (obj.type === 'image_url') { + const imageUrl = obj.image_url as Record | undefined; + return imageUrl !== null && typeof imageUrl === 'object' && typeof imageUrl.url === 'string'; + } + + return false; +} + +/** + * Validate content: string or array of ContentParts + */ +function isValidContent(content: unknown): boolean { + if (typeof content === 'string') { + return true; + } + + if (Array.isArray(content)) { + return content.length > 0 && content.every(isValidContentPart); + } + + return false; +} + /** * Validate envelope structure */ @@ -112,7 +146,7 @@ function isValidEnvelope(data: unknown): data is PromptEnvelope { const obj = data as Record; return ( typeof obj.id === 'string' && - typeof obj.content === 'string' && + isValidContent(obj.content) && obj.meta !== null && typeof obj.meta === 'object' && !Array.isArray(obj.meta) @@ -530,8 +564,8 @@ const envelopeTests: TestDefinition[] = [ if (typeof envelope.id !== 'string') { throw new Error('Envelope field "id" must be a string'); } - if (typeof envelope.content !== 'string') { - throw new Error('Envelope field "content" must be a string'); + if (!isValidContent(envelope.content)) { + throw new Error('Envelope field "content" must be a string or array of ContentParts'); } if (typeof envelope.meta !== 'object' || envelope.meta === null || Array.isArray(envelope.meta)) { throw new Error('Envelope field "meta" must be an object'); diff --git a/tools/registry/src/types.ts b/tools/registry/src/types.ts index 9266487..7af78ea 100644 --- a/tools/registry/src/types.ts +++ b/tools/registry/src/types.ts @@ -48,9 +48,28 @@ export interface TestContext { debug?: boolean; } +// 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; +export type PromptContent = string | ContentPart[]; + export interface PromptEnvelope { id: string; - content: string; + content: PromptContent; meta: Record; }