diff --git a/cspell.json b/cspell.json index f220de4..24d7bb2 100644 --- a/cspell.json +++ b/cspell.json @@ -17,6 +17,7 @@ "packagejson", "prefault", "preinstall", + "ptaas", "remeda", "traceparent", "tracestate", diff --git a/examples/cozeloop-ai-node/src/practice/travel-plan.ts b/examples/cozeloop-ai-node/src/practice/travel-plan.ts index c4a7eae..105b627 100644 --- a/examples/cozeloop-ai-node/src/practice/travel-plan.ts +++ b/examples/cozeloop-ai-node/src/practice/travel-plan.ts @@ -1,6 +1,6 @@ import { type ChatCompletionCreateParams } from 'openai/resources/chat'; import { OpenAI } from 'openai'; -import { cozeLoopTracer, PromptHub, SpanKind } from '@cozeloop/ai'; +import { cozeLoopTracer, PromptHub, type Span, SpanKind } from '@cozeloop/ai'; // initialize tracer globally cozeLoopTracer.initialize({ @@ -23,7 +23,7 @@ async function callLLM(messages: ChatCompletionCreateParams['messages']) { // wrap model as a span node with `cozeLoopTracer.traceable` return await cozeLoopTracer.traceable( - async span => { + async (span: Span) => { cozeLoopTracer.setInput(span, { messages }); const resp = await openai.chat.completions.create({ @@ -64,7 +64,7 @@ async function runTravelPlan(options: TravelPlanOptions) { const messages = hub.formatPrompt(prompt, { ...options }); // invoke model - return callLLM(messages); + return callLLM(messages as ChatCompletionCreateParams['messages']); } async function run() { @@ -77,7 +77,7 @@ async function run() { }; const result = await cozeLoopTracer.traceable( - async span => { + async (span: Span) => { cozeLoopTracer.setInput(span, options); const { choices } = await runTravelPlan(options); diff --git a/examples/cozeloop-ai-node/src/prompt/index.ts b/examples/cozeloop-ai-node/src/prompt/index.ts index 509523d..a3b4331 100644 --- a/examples/cozeloop-ai-node/src/prompt/index.ts +++ b/examples/cozeloop-ai-node/src/prompt/index.ts @@ -1,6 +1,7 @@ import { run as runMultiPart } from './with-multi-part'; import { run as runWithLabel } from './with-label'; import { run as runWithJinja } from './with-jinja'; +import { run as runPTaaS } from './ptaas'; import { run as runBasic } from './hub'; export async function run() { @@ -9,6 +10,7 @@ export async function run() { runWithJinja(), runMultiPart(), runWithLabel(), + runPTaaS(), ]); process.exit(0); diff --git a/examples/cozeloop-ai-node/src/prompt/ptaas.ts b/examples/cozeloop-ai-node/src/prompt/ptaas.ts new file mode 100644 index 0000000..7cd6e32 --- /dev/null +++ b/examples/cozeloop-ai-node/src/prompt/ptaas.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert'; + +import { PromptAsAService } from '@cozeloop/ai'; + +async function runWithNormal() { + const model = new PromptAsAService({ + /** workspace id, use process.env.COZELOOP_WORKSPACE_ID when unprovided */ + // workspaceId: 'your_workspace_id', + apiClient: { + // baseURL: 'api_base_url', + // token: 'your_api_token', + }, + prompt: { + prompt_key: 'CozeLoop_Travel_Master', + version: '0.0.2', + }, + }); + + // 1. model.invoke + const reply = await model.invoke({ + messages: [{ role: 'user', content: '帮我规划轻松旅行' }], + variables: { + departure: '北京', + destination: '上海', + people_num: 2, + days_num: 1, + travel_theme: '亲子', + }, + }); + + assert(reply?.message); + assert(reply.usage); + assert.strictEqual(reply.finish_reason, 'stop'); + + // 2. model.stream + const replyStream = await model.stream({ + messages: [{ role: 'user', content: '帮我规划轻松旅行' }], + variables: { + departure: '北京', + destination: '上海', + people_num: 2, + days_num: 1, + travel_theme: '亲子', + }, + }); + + for await (const chunk of replyStream) { + assert(chunk); + } +} + +async function runWithJinja() { + const model = new PromptAsAService({ + /** workspace id, use process.env.COZELOOP_WORKSPACE_ID when unprovided */ + // workspaceId: 'your_workspace_id', + apiClient: { + // baseURL: 'api_base_url', + // token: 'your_api_token', + }, + prompt: { + prompt_key: 'loop12', + version: '0.0.5', + }, + }); + + // 1. model.invoke + const reply = await model.invoke({ + messages: [{ role: 'user', content: '总结模板内容' }], + variables: { + title: 'Title', + user: { + is_authenticated: false, + name: 'Loop', + }, + items: [{ name: 'fish' }], + place: [{ role: 'assistant', content: '好的' }], + }, + }); + + assert(reply?.message); + assert(reply.usage); + assert.strictEqual(reply.finish_reason, 'stop'); +} + +async function runWithMultiPart() { + const model = new PromptAsAService({ + /** workspace id, use process.env.COZELOOP_WORKSPACE_ID when unprovided */ + // workspaceId: 'your_workspace_id', + apiClient: { + // baseURL: 'api_base_url', + // token: 'your_api_token', + }, + prompt: { + prompt_key: 'loop', + version: '0.0.3', + }, + }); + + const replyStream = await model.stream({ + messages: [{ role: 'user', content: 'respond in 50 words' }], + variables: { + var1: 'sports', + placeholder1: { role: 'assistant', content: 'go on' }, + var2: 'how to play football', + img1: [ + { type: 'text', text: 'text1' }, + { + type: 'image_url', + image_url: { + url: 'https://tinypng.com/static/images/george-anim/large_george_x2.webp', + }, + }, + ], + }, + }); + + for await (const chunk of replyStream) { + assert(chunk); + } +} + +export async function run() { + await Promise.all([ + runWithNormal(), // 普通模板 + runWithJinja(), // Jinja2 模板 + runWithMultiPart(), // 多模态变量 + ]); + + process.exit(); +} + +run(); diff --git a/packages/cozeloop-ai/CHANGELOG.md b/packages/cozeloop-ai/CHANGELOG.md index 38b0c8e..28ac329 100644 --- a/packages/cozeloop-ai/CHANGELOG.md +++ b/packages/cozeloop-ai/CHANGELOG.md @@ -1,5 +1,8 @@ # 🕗 ChangeLog - @cozeloop/ai +## 0.0.9 +* Prompt as a Service (ptaas) + ## 0.0.8 * PromptHub: get prompt with label diff --git a/packages/cozeloop-ai/README.md b/packages/cozeloop-ai/README.md index 5759485..2d2e864 100644 --- a/packages/cozeloop-ai/README.md +++ b/packages/cozeloop-ai/README.md @@ -19,30 +19,44 @@ pnpm install @cozeloop/ai ### 2. Basic Usage ```typescript -import { ApiClient, PromptHub } from '@cozeloop/ai'; +import { ApiClient, PromptHub, PromptAsAService } from '@cozeloop/ai'; -// 1. setup API client +// 1. Setup API client const apiClient = new ApiClient({ baseURL: 'https://api.coze.cn', token: 'your_access_token', }); -// 2. Using prompt hub to get prompt -const promptHub = new PromptHub({ +// 2. Using `PromptHub` or `PromptAsAService` +const hub = new PromptHub({ // or set it as process.env.COZELOOP_WORKSPACE_ID, workspaceId: 'your_workspace_id', apiClient, }); +// hub.getPrompt(key, version); +// hub.formatPrompt(prompt); -const prompt = await promptHub.getPrompt( - 'your_prompt_key', - 'prompt_version (optional)', -); +const model = new PromptAsAService({ + // or set it as process.env.COZELOOP_WORKSPACE_ID, + workspaceId: 'your_workspace_id', + // prompt to invoke as a service + prompt: { + prompt_key: 'your_prompt_key', + }, + apiClient, +}); +// model.invoke({ +// messages: [{ role: 'user', content: 'hi' }], +// }); +// model.stream({ +// messages: [{ role: 'user', content: 'hi' }], +// }); ``` ## Key Features -- 🗄️ **Prompt Hub**: Develop, submit and publish prompts on [CozeLoop](https://loop.coze.cn), and access them it via `PromptHub` -- 🔐 **Authentication Methods**: PAT and JWT +- 🗂️ **Prompt Hub**: Develop, submit and publish prompts on [CozeLoop](https://loop.coze.cn), and access them via `PromptHub` +- 🛠️ **Prompt as a Service**: Develop, submit and publish prompts on [CozeLoop](https://loop.coze.cn), and invoke them as services +- 🔐 **Authentication Methods**: PAT, SAT and JWT - ⚙️ **Configurable**: Timeout, headers, signal, debug options ## Authentication Options diff --git a/packages/cozeloop-ai/README.zh-CN.md b/packages/cozeloop-ai/README.zh-CN.md index 3ec4265..96dbac4 100644 --- a/packages/cozeloop-ai/README.zh-CN.md +++ b/packages/cozeloop-ai/README.zh-CN.md @@ -19,7 +19,7 @@ pnpm install @cozeloop/ai ### 2. 基础用法 ```typescript -import { ApiClient, PromptHub } from '@cozeloop/ai'; +import { ApiClient, PromptHub, PromptAsAService } from '@cozeloop/ai'; // 1. 设置 ApiClient const apiClient = new ApiClient({ @@ -27,21 +27,35 @@ const apiClient = new ApiClient({ token: 'your_access_token', }); -// 2. 使用 PromptHub 获取 Prompt +// 2. 使用 `PromptHub` 或 `PromptAsAService` const promptHub = new PromptHub({ // 或设置环境变量 process.env.COZELOOP_WORKSPACE_ID, workspaceId: 'your_workspace_id', apiClient, }); +// hub.getPrompt(key, version); +// hub.formatPrompt(prompt); -const prompt = await promptHub.getPrompt( - 'your_prompt_key', - 'prompt_version (optional)', -); +const model = new PromptAsAService({ + // 或设置环境变量 process.env.COZELOOP_WORKSPACE_ID, + workspaceId: 'your_workspace_id', + // 要调用的 prompt + prompt: { + prompt_key: 'your_prompt_key', + }, + apiClient, +}); +// model.invoke({ +// messages: [{ role: 'user', content: 'hi' }], +// }); +// model.stream({ +// messages: [{ role: 'user', content: 'hi' }], +// }); ``` ## 主要特性 -- 🗄️ **Prompt Hub**: 在 [CozeLoop](https://loop.coze.cn) 平台开发、提交和发布 Prompt,使用 `PromptHub` 访问 Prompt。 +- 🗂️ **Prompt Hub**: 在 [CozeLoop](https://loop.coze.cn) 平台开发、提交和发布 Prompt,使用 `PromptHub` 访问 Prompt。 +- 🛠️ **Prompt as a Service**: 在 [CozeLoop](https://loop.coze.cn) 平台开发、提交和发布 Prompt,并作为服务调用。 - 🔐 **多种鉴权方式**: PAT and JWT - ⚙️ **可配置**: 超时、请求头、信号、调试 diff --git a/packages/cozeloop-ai/__tests__/__mock__/base-http.ts b/packages/cozeloop-ai/__tests__/__mock__/base-http.ts index a2097b6..18a1973 100644 --- a/packages/cozeloop-ai/__tests__/__mock__/base-http.ts +++ b/packages/cozeloop-ai/__tests__/__mock__/base-http.ts @@ -9,6 +9,12 @@ import { fileToStreamResp, headersToJson, setupMockServer } from './utils'; export function setupBaseHttpMock() { const mockServer = setupServer( + http.post(/\/stream-event-error/i, () => + fileToStreamResp(join(__dirname, 'base-stream-event-error.txt')), + ), + http.post(/\/stream-parse-error/i, () => + fileToStreamResp(join(__dirname, 'base-stream-parse-error.txt')), + ), http.post(/\/stream/i, () => fileToStreamResp(join(__dirname, 'base-stream.txt')), ), diff --git a/packages/cozeloop-ai/__tests__/__mock__/base-stream-event-error.txt b/packages/cozeloop-ai/__tests__/__mock__/base-stream-event-error.txt new file mode 100644 index 0000000..76936e7 --- /dev/null +++ b/packages/cozeloop-ai/__tests__/__mock__/base-stream-event-error.txt @@ -0,0 +1,5 @@ +data: {"seq": 1} + +event: error +data: 500 Bad Gateway + diff --git a/packages/cozeloop-ai/__tests__/__mock__/base-stream-parse-error.txt b/packages/cozeloop-ai/__tests__/__mock__/base-stream-parse-error.txt new file mode 100644 index 0000000..c49bac5 --- /dev/null +++ b/packages/cozeloop-ai/__tests__/__mock__/base-stream-parse-error.txt @@ -0,0 +1,2 @@ +data: {"seq"1: 1} + diff --git a/packages/cozeloop-ai/__tests__/__mock__/base-stream.txt b/packages/cozeloop-ai/__tests__/__mock__/base-stream.txt index 1baed73..3a97a31 100644 --- a/packages/cozeloop-ai/__tests__/__mock__/base-stream.txt +++ b/packages/cozeloop-ai/__tests__/__mock__/base-stream.txt @@ -3,3 +3,4 @@ data: {"seq": 1} data: {"seq": 2} data: {"seq": 3} + diff --git a/packages/cozeloop-ai/__tests__/__mock__/ptaas-stream.txt b/packages/cozeloop-ai/__tests__/__mock__/ptaas-stream.txt new file mode 100644 index 0000000..cb55a14 --- /dev/null +++ b/packages/cozeloop-ai/__tests__/__mock__/ptaas-stream.txt @@ -0,0 +1,84 @@ +data: {"message":{"role":"assistant","content":""},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"**"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"国"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"庆"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"长"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"假"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"**\\n\\n"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"山"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"川"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"河"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"海"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"舞"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"锦"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"绣"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":","},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"人"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"间"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"繁"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"华"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"庆"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"六"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"合"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"。"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":" \\n"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"载"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"歌"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"载"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"舞"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"庆"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"华"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"诞"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":","},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"共"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"庆"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"华"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"诞"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"风"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"景"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"好"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":"。"},"finish_reason":""} + +data: {"message":{"role":"assistant","content":""},"finish_reason":"stop"} + +data: {"message":{"role":"assistant","content":""},"finish_reason":"","usage":{"output_tokens":603,"input_tokens":468}} + diff --git a/packages/cozeloop-ai/__tests__/__mock__/ptaas.ts b/packages/cozeloop-ai/__tests__/__mock__/ptaas.ts new file mode 100644 index 0000000..8cb6784 --- /dev/null +++ b/packages/cozeloop-ai/__tests__/__mock__/ptaas.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +import { join } from 'node:path'; + +import { setupServer } from 'msw/node'; +import { http, passthrough } from 'msw'; + +import { type ExecutePromptReply } from '../../src'; +import { fileToStreamResp, setupMockServer, successResp } from './utils'; + +const basicReply: ExecutePromptReply = { + message: { + role: 'assistant', + content: + '**国庆长假**\\n\\n金秋国庆迎盛世,游人欢笑赏风景。 \\n家国同庆情更浓,祝福祖国繁荣富强。', + reasoning_content: '', + tool_call_id: '', + }, + finish_reason: 'stop', + usage: { input_tokens: 130, output_tokens: 60 }, +}; + +export function setupPTaaSMock() { + const mockServer = setupServer( + http.all(/.*/, req => { + const pass = Boolean(req.request.headers.get('x-pass')); + return pass ? passthrough() : undefined; + }), + http.post(/\/v1\/loop\/prompts\/execute$/, req => { + const executeType = req.request.headers.get('x-mock'); + + switch (executeType) { + case 'basic-invoke': + return successResp(basicReply); + default: + return passthrough(); + } + }), + http.post(/\/v1\/loop\/prompts\/execute_streaming/, req => { + const executeType = req.request.headers.get('x-mock'); + + switch (executeType) { + case 'basic-stream': + return fileToStreamResp(join(__dirname, 'ptaas-stream.txt')); + default: + return passthrough(); + } + }), + ); + + return setupMockServer(mockServer); +} diff --git a/packages/cozeloop-ai/__tests__/__mock__/utils.ts b/packages/cozeloop-ai/__tests__/__mock__/utils.ts index c4b753b..21d2e4c 100644 --- a/packages/cozeloop-ai/__tests__/__mock__/utils.ts +++ b/packages/cozeloop-ai/__tests__/__mock__/utils.ts @@ -13,14 +13,35 @@ export function headersToJson(headers: Headers) { return obj; } +function randomSplitString(input?: string): string[] { + if (!input?.length) { + return []; + } + + const result: string[] = []; + const size = Math.floor(Math.random() * input.length) + 1; + for (let i = 0; i < input.length; i += size) { + result.push(input.slice(i, i + size)); + } + + return result; +} + export async function fileToStreamResp(fileName: string) { - const lines = (await readFile(fileName, 'utf-8')).toString().split('\n'); + const lines = (await readFile(fileName, 'utf-8')).toString().split('\n\n'); const stream = new ReadableStream({ start(controller) { const total = lines.length; for (let i = 0; i < total; i++) { - const suffix = i === total - 1 ? '' : '\n\n'; - controller.enqueue(`${lines[i]}${suffix}`); + if (!lines[i]) { + continue; + } + const str = i === total - 1 ? lines[i] : `${lines[i]}\n\n`; + const pieces = randomSplitString(str); + + for (const it of pieces) { + controller.enqueue(it); + } } controller.close(); }, diff --git a/packages/cozeloop-ai/__tests__/api/api-client.test.ts b/packages/cozeloop-ai/__tests__/api/api-client.test.ts index dfb6165..410592f 100644 --- a/packages/cozeloop-ai/__tests__/api/api-client.test.ts +++ b/packages/cozeloop-ai/__tests__/api/api-client.test.ts @@ -43,6 +43,34 @@ describe('Http Test', () => { } }); + it('Basic POST streaming with parse error', async () => { + const resp = await apiClient.post>( + '/stream-parse-error', + undefined, + true, + ); + + await expect(async () => { + for await (const chunk of resp) { + console.info(chunk); + } + }).rejects.toThrowError(SyntaxError); + }); + + it('Basic POST streaming with event error', async () => { + const resp = await apiClient.post>( + '/stream-event-error', + undefined, + true, + ); + + await expect(async () => { + for await (const chunk of resp) { + console.info(chunk); + } + }).rejects.toThrowError(/500 Bad Gateway/); + }); + it('Basic PUT', async () => { const resp = await apiClient.put('/basic'); diff --git a/packages/cozeloop-ai/__tests__/auth/utils.test.ts b/packages/cozeloop-ai/__tests__/auth/utils.test.ts new file mode 100644 index 0000000..780704a --- /dev/null +++ b/packages/cozeloop-ai/__tests__/auth/utils.test.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT +import { delay, randomStr } from '../../src/auth/utils'; + +describe('delay', () => { + it('should delay for the specified amount of time', async () => { + const start = Date.now(); + const delayTime = 100; // Delay for 100 milliseconds + + await delay(delayTime); + + const end = Date.now(); + const elapsed = end - start; + + // Allow for a small margin of error (e.g., 5 milliseconds) + expect(elapsed).toBeGreaterThanOrEqual(delayTime - 5); + expect(elapsed).toBeLessThanOrEqual(delayTime + 5); + }); +}); + +describe('randomStr', () => { + it('should generate a random string', () => { + const randomString1 = randomStr(); + const randomString2 = randomStr(); + + expect(randomString1).toBeTruthy(); + expect(randomString2).toBeTruthy(); + expect(randomString1).not.toEqual(randomString2); + }); +}); diff --git a/packages/cozeloop-ai/__tests__/prompt/converter.test.ts b/packages/cozeloop-ai/__tests__/prompt/converter.test.ts new file mode 100644 index 0000000..b9765f1 --- /dev/null +++ b/packages/cozeloop-ai/__tests__/prompt/converter.test.ts @@ -0,0 +1,327 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +import { + toLoopToolCalls, + toLoopPart, + toLoopMessage, + toLoopMessages, + toVariableVal, + toVariableVals, +} from '../../src/prompt/converter'; +import { + type ToolCall, + type ContentPart, + type Message, + type PromptVariables, +} from '../../src/prompt'; + +describe('toLoopToolCalls', () => { + it('should return undefined when input is undefined', () => { + expect(toLoopToolCalls(undefined)).toBeUndefined(); + }); + + it('should return an empty array when input is an empty array', () => { + expect(toLoopToolCalls([])).toEqual([]); + }); + + it('should convert ToolCall array to LoopToolCall array', () => { + const toolCalls: ToolCall[] = [ + { + id: '1', + type: 'function', + function: { name: 'search', arguments: 'query=hello' }, + }, + { + id: '2', + type: 'function', + function: { name: 'analyze', arguments: 'text=world' }, + }, + ]; + + const expected = [ + { + id: '1', + type: 'function', + function_call: { name: 'search', arguments: 'query=hello' }, + }, + { + id: '2', + type: 'function', + function_call: { name: 'analyze', arguments: 'text=world' }, + }, + ]; + + expect(toLoopToolCalls(toolCalls)).toEqual(expected); + }); +}); + +describe('toLoopPart', () => { + it('should convert ContentPartText to LoopContentPart', () => { + const contentPart: ContentPart = { type: 'text', text: 'hello' }; + const expected = { type: 'text', text: 'hello' }; + + expect(toLoopPart(contentPart)).toEqual(expected); + }); + + it('should convert ContentPartImage to LoopContentPart', () => { + const contentPart: ContentPart = { + type: 'image_url', + image_url: { url: 'https://example.com/image.jpg' }, + }; + const expected = { + type: 'image_url', + image_url: 'https://example.com/image.jpg', + }; + + expect(toLoopPart(contentPart)).toEqual(expected); + }); + + it('should throw an error for unsupported content type', () => { + const contentPart: any = { type: 'unknown', data: 'invalid' }; + expect(() => toLoopPart(contentPart)).toThrowError( + '[toLoopPart] unknown unsupported', + ); + }); +}); + +describe('toLoopMessage', () => { + it('should convert system Message to LoopMessage', () => { + const message: Message = { role: 'system', content: 'system message' }; + const expected = { role: 'system', content: 'system message' }; + + expect(toLoopMessage(message)).toEqual(expected); + }); + + it('should convert user Message to LoopMessage', () => { + const message: Message = { + role: 'user', + content: 'user message', + parts: [{ type: 'text', text: 'hello' }], + }; + const expected = { + role: 'user', + content: 'user message', + parts: [{ type: 'text', text: 'hello' }], + }; + + expect(toLoopMessage(message)).toEqual(expected); + }); + + it('should convert tool Message to LoopMessage', () => { + const message: Message = { + role: 'tool', + content: 'tool message', + tool_call_id: '1', + }; + const expected = { + role: 'tool', + content: 'tool message', + tool_call_id: '1', + }; + + expect(toLoopMessage(message)).toEqual(expected); + }); + + it('should convert assistant Message to LoopMessage', () => { + const message: Message = { + role: 'assistant', + content: 'assistant message', + parts: [ + { + type: 'image_url', + image_url: { url: 'https://example.com/image.jpg' }, + }, + ], + tool_calls: [ + { + id: '1', + type: 'function', + function: { name: 'search', arguments: 'query=hello' }, + }, + ], + }; + const expected = { + role: 'assistant', + content: 'assistant message', + parts: [ + { type: 'image_url', image_url: 'https://example.com/image.jpg' }, + ], + tool_calls: [ + { + id: '1', + type: 'function', + function_call: { name: 'search', arguments: 'query=hello' }, + }, + ], + }; + + expect(toLoopMessage(message)).toEqual(expected); + }); + + it('should throw an error for unsupported role', () => { + const message: any = { role: 'unknown', content: 'invalid message' }; + expect(() => toLoopMessage(message)).toThrowError( + '[toLoopMessage] unknown unsupported', + ); + }); +}); + +describe('toLoopMessages', () => { + it('should return an empty array when input is undefined or empty', () => { + expect(toLoopMessages(undefined)).toEqual([]); + expect(toLoopMessages([])).toEqual([]); + }); + + it('should convert Message array to LoopMessage array', () => { + const messages: Message[] = [ + { role: 'system', content: 'system message' }, + { + role: 'user', + content: 'user message', + parts: [{ type: 'text', text: 'hello' }], + }, + { role: 'tool', content: 'tool message', tool_call_id: '1' }, + ]; + const expected = [ + { role: 'system', content: 'system message' }, + { + role: 'user', + content: 'user message', + parts: [{ type: 'text', text: 'hello' }], + }, + { role: 'tool', content: 'tool message', tool_call_id: '1' }, + ]; + + expect(toLoopMessages(messages)).toEqual(expected); + }); +}); + +describe('toVariableVal', () => { + it('should convert primitive value to VariableVal', () => { + const key = 'stringValue'; + const value = 'hello'; + const expected = { key, value }; + + expect(toVariableVal(key, value)).toEqual(expected); + }); + + it('should convert null to undefined', () => { + const key = 'nullValue'; + const value = null; + + expect(toVariableVal(key, value)).toBeUndefined(); + }); + + it('should convert Message to VariableVal', () => { + const key = 'messageValue'; + const value: Message = { role: 'user', content: 'user message' }; + const expected = { + key, + placeholder_messages: [{ role: 'user', content: 'user message' }], + }; + + expect(toVariableVal(key, value)).toEqual(expected); + }); + + it('should convert Message array to VariableVal', () => { + const key = 'messageArrayValue'; + const value: Message[] = [ + { role: 'system', content: 'system message' }, + { role: 'user', content: 'user message' }, + ]; + const expected = { + key, + placeholder_messages: [ + { role: 'system', content: 'system message' }, + { role: 'user', content: 'user message' }, + ], + }; + + expect(toVariableVal(key, value)).toEqual(expected); + }); + + it('should convert ContentPart array to VariableVal', () => { + const key = 'contentPartArrayValue'; + const value: ContentPart[] = [ + { type: 'text', text: 'hello' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/image.jpg' }, + }, + ]; + const expected = { + key, + multi_part_values: [ + { type: 'text', text: 'hello' }, + { type: 'image_url', image_url: 'https://example.com/image.jpg' }, + ], + }; + + expect(toVariableVal(key, value)).toEqual(expected); + }); + + it('should return undefined for unsupported value types', () => { + const key = 'functionValue'; + const value = () => { + /** no-op */ + }; + + expect(toVariableVal(key, value)).toBeUndefined(); + }); +}); + +describe('toVariableVals', () => { + it('should return undefined when input is undefined', () => { + expect(toVariableVals(undefined)).toBeUndefined(); + }); + + it('should convert PromptVariables to VariableVal array', () => { + const variables: PromptVariables = { + stringValue: 'hello', + numberValue: 42, + booleanValue: true, + messageValue: { role: 'user', content: 'user message' }, + messageArrayValue: [ + { role: 'system', content: 'system message' }, + { role: 'user', content: 'user message' }, + ], + contentPartArrayValue: [ + { type: 'text', text: 'hello' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/image.jpg' }, + }, + ], + nullValue: null, + undefinedValue: undefined, + functionValue: () => { + /** no-op */ + }, + }; + const expected = [ + { key: 'stringValue', value: 'hello' }, + { key: 'numberValue', value: '42' }, + { key: 'booleanValue', value: 'true' }, + { + key: 'messageValue', + placeholder_messages: [{ role: 'user', content: 'user message' }], + }, + { + key: 'messageArrayValue', + placeholder_messages: [ + { role: 'system', content: 'system message' }, + { role: 'user', content: 'user message' }, + ], + }, + { + key: 'contentPartArrayValue', + multi_part_values: [ + { type: 'text', text: 'hello' }, + { type: 'image_url', image_url: 'https://example.com/image.jpg' }, + ], + }, + ]; + expect(toVariableVals(variables)).toEqual(expected); + }); +}); diff --git a/packages/cozeloop-ai/__tests__/prompt/guard.test.ts b/packages/cozeloop-ai/__tests__/prompt/guard.test.ts new file mode 100644 index 0000000..24c256e --- /dev/null +++ b/packages/cozeloop-ai/__tests__/prompt/guard.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +import { + isContentPart, + isContentPartArr, + isMessage, + isMessageArr, +} from '../../src/prompt/guard'; + +describe('isMessage', () => { + it('should return true for valid message objects', () => { + const validMessage = { role: 'user', content: 'Hello' }; + expect(isMessage(validMessage)).toBe(true); + }); + + it('should return false for non-object values', () => { + expect(isMessage(null)).toBe(false); + expect(isMessage(undefined)).toBe(false); + expect(isMessage(123)).toBe(false); + expect(isMessage('hello')).toBe(false); + }); + + it('should return false for objects with invalid role', () => { + const invalidMessage = { role: 'invalid', content: 'Hello' }; + expect(isMessage(invalidMessage)).toBe(false); + }); +}); + +describe('isMessageArr', () => { + it('should return true for an array of valid message objects', () => { + const validMessages = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + ]; + expect(isMessageArr(validMessages)).toBe(true); + }); + + it('should return false for non-array values', () => { + expect(isMessageArr(null)).toBe(false); + expect(isMessageArr(undefined)).toBe(false); + expect(isMessageArr(123)).toBe(false); + expect(isMessageArr('hello')).toBe(false); + }); + + it('should return false for an array containing invalid message objects', () => { + const invalidMessages = [ + { role: 'user', content: 'Hello' }, + { role: 'invalid', content: 'Hi there!' }, + ]; + expect(isMessageArr(invalidMessages)).toBe(false); + }); +}); + +describe('isContentPart', () => { + it('should return true for valid text content parts', () => { + const validTextPart = { type: 'text', text: 'Hello' }; + expect(isContentPart(validTextPart)).toBe(true); + }); + + it('should return true for valid image content parts', () => { + const validImagePart = { + type: 'image_url', + image_url: { url: 'https://example.com/image.jpg' }, + }; + expect(isContentPart(validImagePart)).toBe(true); + }); + + it('should return false for non-object values', () => { + expect(isContentPart(null)).toBe(false); + expect(isContentPart(undefined)).toBe(false); + expect(isContentPart(123)).toBe(false); + expect(isContentPart('hello')).toBe(false); + }); + + it('should return false for objects without a valid type', () => { + const invalidPart = { type: 'invalid', text: 'Hello' }; + expect(isContentPart(invalidPart)).toBe(false); + }); +}); + +describe('isContentPartArr', () => { + it('should return true for an array of valid content parts', () => { + const validParts = [ + { type: 'text', text: 'Hello' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/image.jpg' }, + }, + ]; + expect(isContentPartArr(validParts)).toBe(true); + }); + + it('should return false for non-array values', () => { + expect(isContentPartArr(null)).toBe(false); + expect(isContentPartArr(undefined)).toBe(false); + expect(isContentPartArr(123)).toBe(false); + expect(isContentPartArr('hello')).toBe(false); + }); + + it('should return false for an array containing invalid content parts', () => { + const invalidParts = [ + { type: 'text', text: 'Hello' }, + { type: 'invalid', text: 'Hi there!' }, + ]; + expect(isContentPartArr(invalidParts)).toBe(false); + }); +}); diff --git a/packages/cozeloop-ai/__tests__/prompt/hub.test.ts b/packages/cozeloop-ai/__tests__/prompt/hub.test.ts index 37a0447..ebf2459 100644 --- a/packages/cozeloop-ai/__tests__/prompt/hub.test.ts +++ b/packages/cozeloop-ai/__tests__/prompt/hub.test.ts @@ -9,7 +9,7 @@ import { simpleConsoleLogger } from '../../src/utils/logger'; import { cozeLoopTracer } from '../../src/tracer'; import { PromptCache } from '../../src/prompt/cache'; import { PromptHub, type PromptVariables } from '../../src/prompt'; -import { PromptApi, type TemplateMessage } from '../../src/api'; +import { PromptApi, type LoopMessage } from '../../src/api'; config(); @@ -47,6 +47,7 @@ describe('Test Prompt Hub', () => { expect(prompt?.prompt_key).toBe(key); expect(prompt?.version).toBe(version); + expect(hub.cache).toBeInstanceOf(PromptCache); // 1) format prompt without variables (() => { @@ -61,7 +62,7 @@ describe('Test Prompt Hub', () => { // var1: str // placeholder: messages (() => { - const placeholderMessages: TemplateMessage[] = [ + const placeholderMessages: LoopMessage[] = [ { role: 'assistant', content: 'fake_content' }, { role: 'user', content: 'fake_content' }, ]; @@ -90,7 +91,7 @@ describe('Test Prompt Hub', () => { }; const messages = hub.formatPrompt(prompt, variables); - expect(messages[0].content.includes('{{var1}}')).toBe(false); + expect(messages[0].content?.includes('{{var1}}')).toBe(false); expect(messages[1].content).contains(variables.var2); })(); }); @@ -125,7 +126,7 @@ describe('Test Prompt Hub', () => { apiClient: { headers: { 'x-template-type': 'jinja2' }, // for mock }, - traceable: false, + traceable: true, }); const key = 'loop12'; diff --git a/packages/cozeloop-ai/__tests__/prompt/ptaas.test.ts b/packages/cozeloop-ai/__tests__/prompt/ptaas.test.ts new file mode 100644 index 0000000..888748b --- /dev/null +++ b/packages/cozeloop-ai/__tests__/prompt/ptaas.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +import { setupPTaaSMock } from '../__mock__/ptaas'; +import { PromptAsAService } from '../../src'; + +describe('Test Prompt As a Service', () => { + const httpMock = setupPTaaSMock(); + beforeAll(async () => { + await httpMock.start(); + }); + afterAll(async () => { + await httpMock.close(); + }); + afterEach(() => httpMock.reset()); + + it('#1 basic invoke', async () => { + const model = new PromptAsAService({ + prompt: { + prompt_key: 'CozeLoop_Travel_Master', + }, + apiClient: { + headers: { 'x-mock': 'basic-invoke' }, + }, + }); + + const reply = await model.invoke({ + messages: [{ role: 'user', content: '做一首诗' }], + }); + + expect(reply?.finish_reason).toBe('stop'); + expect(reply?.message.role).toBe('assistant'); + }); + + it('#2 basic stream', async () => { + const model = new PromptAsAService({ + prompt: { + prompt_key: 'CozeLoop_Travel_Master1', + }, + apiClient: { + headers: { 'x-mock': 'basic-stream' }, + }, + }); + + const replyStream = await model.stream({ + messages: [{ role: 'user', content: '做一首诗' }], + }); + + for await (const chunk of replyStream) { + expect(chunk.message).toBeTruthy(); + } + }); +}); diff --git a/packages/cozeloop-ai/__tests__/prompt/trace.test.ts b/packages/cozeloop-ai/__tests__/prompt/trace.test.ts index e478622..b68bbf2 100644 --- a/packages/cozeloop-ai/__tests__/prompt/trace.test.ts +++ b/packages/cozeloop-ai/__tests__/prompt/trace.test.ts @@ -7,7 +7,7 @@ import { toPromptTemplateInput, toPromptTemplateOutput, } from '../../src/prompt/trace'; -import type { TemplateMessage } from '../../src/api'; +import type { LoopMessage } from '../../src/api'; // Mock serializeTagValue to verify calls vi.mock('../../src/tracer/utils', () => ({ @@ -54,7 +54,7 @@ describe('🧪 Prompt Trace Functions', () => { describe('toPromptTemplateInput', () => { it('should serialize template input with messages and variable map', () => { - const messages: TemplateMessage[] = [ + const messages: LoopMessage[] = [ { role: 'system', content: 'You are a helpful assistant', @@ -88,7 +88,7 @@ describe('🧪 Prompt Trace Functions', () => { }); it('should handle empty messages and variable map', () => { - const messages: TemplateMessage[] = []; + const messages: LoopMessage[] = []; const variableMap: PromptVariables = {}; const result = toPromptTemplateInput(messages, variableMap); @@ -101,7 +101,7 @@ describe('🧪 Prompt Trace Functions', () => { }); it('should handle only messages without variable map', () => { - const messages: TemplateMessage[] = [ + const messages: LoopMessage[] = [ { role: 'user', content: 'Hello', diff --git a/packages/cozeloop-ai/package.json b/packages/cozeloop-ai/package.json index 4a1e94e..785efde 100644 --- a/packages/cozeloop-ai/package.json +++ b/packages/cozeloop-ai/package.json @@ -1,6 +1,6 @@ { "name": "@cozeloop/ai", - "version": "0.0.8", + "version": "0.0.9", "description": "Official Node.js SDK of CozeLoop | 扣子罗盘官方 Node.js SDK", "keywords": [ "cozeloop", diff --git a/packages/cozeloop-ai/src/api/api-client/utils.ts b/packages/cozeloop-ai/src/api/api-client/utils.ts index 5e3c7c9..30abc0a 100644 --- a/packages/cozeloop-ai/src/api/api-client/utils.ts +++ b/packages/cozeloop-ai/src/api/api-client/utils.ts @@ -83,8 +83,8 @@ export function parseEventChunk(chunk: string) { } } - if (event === 'gateway-error') { - throw new Error(data || 'gateway-error'); + if (event.includes('error')) { + throw new Error(data || 'SSE event error'); } return JSON.parse(data) as T; diff --git a/packages/cozeloop-ai/src/api/prompt/index.ts b/packages/cozeloop-ai/src/api/prompt/index.ts index 67b7df9..9d17c42 100644 --- a/packages/cozeloop-ai/src/api/prompt/index.ts +++ b/packages/cozeloop-ai/src/api/prompt/index.ts @@ -1,9 +1,17 @@ // Copyright (c) 2025 Bytedance Ltd. and/or its affiliates // SPDX-License-Identifier: MIT +import { type PullPromptReq, type PullPromptResp } from './types/prompt'; +import { + type ExecutePromptResp, + type ExecutePromptReq, + type StreamingExecutePromptResp, +} from './types/execute'; import { BaseApi } from '../base'; -import type { PullPromptReq, PullPromptResp } from './types'; +import { type RequestOptions } from '../api-client'; -export type * from './types'; +export type * from './types/common'; +export type * from './types/execute'; +export type * from './types/prompt'; export class PromptApi extends BaseApi { pullPrompt(req: PullPromptReq) { @@ -11,4 +19,21 @@ export class PromptApi extends BaseApi { return this._client.post(url, req); } + + executePrompt(req: ExecutePromptReq, options?: RequestOptions) { + const url = '/v1/loop/prompts/execute'; + + return this._client.post(url, req, false, options); + } + + streamingExecutePrompt(req: ExecutePromptReq, options?: RequestOptions) { + const url = '/v1/loop/prompts/execute_streaming'; + + return this._client.post( + url, + req, + true, + options, + ); + } } diff --git a/packages/cozeloop-ai/src/api/prompt/types/common.ts b/packages/cozeloop-ai/src/api/prompt/types/common.ts new file mode 100644 index 0000000..8a1644c --- /dev/null +++ b/packages/cozeloop-ai/src/api/prompt/types/common.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +export type LoopContentType = + | 'text' + | 'image_url' + | 'base64_data' + | 'multi_part_variable'; + +export interface LoopContentPart { + type: LoopContentType; + text?: string; + image_url?: string; + base64_data?: string; +} + +export type LoopRole = 'system' | 'user' | 'tool' | 'assistant' | 'placeholder'; + +export interface LoopMessage { + /** 角色 */ + role: LoopRole; + /** 消息内容 */ + content?: string; + /** 多模态内容 */ + parts?: LoopContentPart[]; + /** 推理思考内容 */ + reasoning_content?: string; + /** tool调用ID(role为tool时有效) */ + tool_call_id?: string; + /** tool调用(role为assistant时有效) */ + tool_calls?: LoopToolCall[]; +} + +export interface LoopTool { + type: 'function'; + function: { + name: string; + description: string; + parameters: string; + }; +} + +export interface LoopToolCall { + index?: number; + id?: string; + type?: 'function'; + function_call?: { + name?: string; + arguments?: string; + }; +} + +export interface LoopToolCallConfig { + tool_choice?: 'auto' | 'none'; +} + +export type VariableType = + | 'string' + | 'boolean' + | 'integer' + | 'float' + | 'object' + | 'array' + | 'array' + | 'array' + | 'array' + | 'array' + | 'placeholder' + | 'multi_part'; + +export interface VariableDef { + key: string; + type: VariableType; + desc?: string; +} + +export interface VariableVal { + /** 变量key */ + key?: string; + /** 普通变量值(非string类型,如boolean、integer、float、object等,序列化后传入)*/ + value?: string; + /** placeholder变量值 */ + placeholder_messages?: LoopMessage[]; + /** 多模态变量值 */ + multi_part_values?: LoopContentPart[]; +} diff --git a/packages/cozeloop-ai/src/api/prompt/types/execute.ts b/packages/cozeloop-ai/src/api/prompt/types/execute.ts new file mode 100644 index 0000000..4fb0e94 --- /dev/null +++ b/packages/cozeloop-ai/src/api/prompt/types/execute.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT + +import { type BaseApiResp } from '../../base'; +import { type PromptQuery } from './prompt'; +import { type VariableVal, type LoopMessage } from './common'; + +export interface ExecutePromptReq { + /** 工作空间ID */ + workspace_id?: string; + /** Prompt 标识 */ + prompt_identifier?: PromptQuery; + /** 变量值 */ + variable_vals?: VariableVal[]; + /** 消息 */ + messages?: LoopMessage[]; +} + +export interface TokenUsage { + input_tokens?: number; + output_tokens?: number; +} + +export interface ExecutePromptReply { + message: LoopMessage; + finish_reason: string; + usage: TokenUsage; +} + +export type ExecutePromptResp = BaseApiResp; + +export type StreamingExecutePromptResp = AsyncGenerator; diff --git a/packages/cozeloop-ai/src/api/prompt/types.ts b/packages/cozeloop-ai/src/api/prompt/types/prompt.ts similarity index 52% rename from packages/cozeloop-ai/src/api/prompt/types.ts rename to packages/cozeloop-ai/src/api/prompt/types/prompt.ts index 333f291..997f7c4 100644 --- a/packages/cozeloop-ai/src/api/prompt/types.ts +++ b/packages/cozeloop-ai/src/api/prompt/types/prompt.ts @@ -1,45 +1,34 @@ // Copyright (c) 2025 Bytedance Ltd. and/or its affiliates // SPDX-License-Identifier: MIT -import type { BaseApiResp } from '../base'; -export interface TemplateContentPart { - /** variable type */ - type: 'text' | 'multi_part_variable'; - /** variable name */ - text: string; -} +import { type BaseApiResp } from '../../base'; +import { + type LoopMessage, + type LoopTool, + type LoopToolCallConfig, + type VariableDef, +} from './common'; -export interface TemplateMessage { - role: 'system' | 'user' | 'assistant' | 'placeholder'; - content: string; - parts?: TemplateContentPart[]; +export interface PromptQuery { + /** prompt_key */ + prompt_key?: string; + /** prompt版本 */ + version?: string; + /** prompt版本标识(如果version不为空,该字段会被忽略) */ + label?: string; } -export interface VariableDef { - key: string; - type: 'string' | 'placeholder' | 'multi_part'; - desc?: string; +export interface PullPromptReq { + workspace_id: string; + queries: PromptQuery[]; } export interface PromptTemplate { template_type: 'normal' | 'jinja2'; - messages: TemplateMessage[]; + messages: LoopMessage[]; variable_defs: VariableDef[]; } -export interface Tool { - type: 'function'; - function: { - name: string; - description: string; - parameters: string; - }; -} - -export interface ToolCallConfig { - tool_choice?: 'auto' | 'none'; -} - export interface LLMConfig { temperature?: number; max_tokens?: number; @@ -55,23 +44,11 @@ export interface Prompt { prompt_key: string; version: string; prompt_template: PromptTemplate; - tools: Tool[]; - tool_call_config?: ToolCallConfig; + tools: LoopTool[]; + tool_call_config?: LoopToolCallConfig; llm_config: LLMConfig; } -export interface PromptQuery { - prompt_key: string; - version?: string; - /** priority: version > label */ - label?: string; -} - -export interface PullPromptReq { - workspace_id: string; - queries: PromptQuery[]; -} - export interface PromptResultItem { query: PromptQuery; prompt: Prompt; diff --git a/packages/cozeloop-ai/src/global.d.ts b/packages/cozeloop-ai/src/global.d.ts index 4666dab..aeb036f 100644 --- a/packages/cozeloop-ai/src/global.d.ts +++ b/packages/cozeloop-ai/src/global.d.ts @@ -34,6 +34,7 @@ declare module 'process' { /** SDK Version, which is injected via vitest or tsup from package.json */ COZELOOP_VERSION: string; COZELOOP_API_TOKEN?: string; + COZELOOP_WORKSPACE_ID?: string; } } } diff --git a/packages/cozeloop-ai/src/index.ts b/packages/cozeloop-ai/src/index.ts index 28ebc4e..103c1e6 100644 --- a/packages/cozeloop-ai/src/index.ts +++ b/packages/cozeloop-ai/src/index.ts @@ -9,7 +9,7 @@ export { OAuthJWTFlow } from './auth'; export type * from './auth'; // prompt -export { PromptHub } from './prompt'; +export { PromptHub, PromptAsAService } from './prompt'; export type * from './prompt'; // tracer diff --git a/packages/cozeloop-ai/src/prompt/converter.ts b/packages/cozeloop-ai/src/prompt/converter.ts new file mode 100644 index 0000000..c2ff380 --- /dev/null +++ b/packages/cozeloop-ai/src/prompt/converter.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT +import { stringifyVal } from '../utils/common'; +import { + type LoopToolCall, + type LoopMessage, + type LoopContentPart, + type VariableVal, +} from '../api'; +import { + type ToolCall, + type Message, + type PromptVariables, + type ContentPart, +} from './types'; +import { isContentPartArr, isMessage, isMessageArr } from './guard'; + +export function toLoopToolCalls(toolCalls?: ToolCall[]) { + if (typeof toolCalls === 'undefined') { + return undefined; + } + + if (!toolCalls.length) { + return []; + } + + return toolCalls.map(it => ({ + id: it.id, + type: 'function', + function_call: { + name: it.function.name, + arguments: it.function.arguments, + }, + })); +} + +export function toLoopPart(part: ContentPart): LoopContentPart { + const { type } = part; + switch (type) { + case 'text': + return { type: 'text', text: part.text }; + case 'image_url': + return { type: 'image_url', image_url: part.image_url.url }; + default: + throw new Error(`[toLoopPart] ${type} unsupported`); + } +} + +export function toLoopMessage(message: Message): LoopMessage { + switch (message.role) { + case 'system': + return { + role: 'system', + content: message.content, + }; + case 'user': + return { + role: 'user', + content: message.content, + parts: message.parts?.map(it => toLoopPart(it)), + }; + case 'tool': + return { + role: 'tool', + content: message.content, + tool_call_id: message.tool_call_id, + }; + case 'assistant': + return { + role: 'assistant', + content: message.content, + parts: message.parts?.map(it => toLoopPart(it)), + tool_calls: toLoopToolCalls(message.tool_calls), + }; + default: + throw new Error(`[toLoopMessage] ${message.role} unsupported`); + } +} + +export function toLoopMessages(messages?: Message[]) { + if (!messages?.length) { + return []; + } + + return messages.map(it => toLoopMessage(it)); +} + +export function toVariableVal( + key: string, + val: PromptVariables[keyof PromptVariables], +): VariableVal | undefined { + switch (typeof val) { + case 'string': + case 'symbol': + case 'bigint': + case 'number': + case 'boolean': + return { key, value: stringifyVal(val) }; + case 'object': { + if (val === null) { + return undefined; + } + + if (isMessage(val)) { + return { key, placeholder_messages: [toLoopMessage(val)] }; + } + + if (isMessageArr(val)) { + return { key, placeholder_messages: toLoopMessages(val) }; + } + + if (isContentPartArr(val)) { + return { key, multi_part_values: val.map(it => toLoopPart(it)) }; + } + + return { key, value: stringifyVal(val) }; + } + case 'undefined': + case 'function': + default: + return undefined; + } +} + +export function toVariableVals(variables?: PromptVariables) { + if (typeof variables === 'undefined') { + return undefined; + } + + const vals: VariableVal[] = []; + + for (const [key, val] of Object.entries(variables)) { + const vv = toVariableVal(key, val); + vv && vals.push(vv); + } + + return vals; +} diff --git a/packages/cozeloop-ai/src/prompt/guard.ts b/packages/cozeloop-ai/src/prompt/guard.ts new file mode 100644 index 0000000..00bc502 --- /dev/null +++ b/packages/cozeloop-ai/src/prompt/guard.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT +import { type ContentPart, type Message } from './types'; + +export function isMessage(val: unknown): val is Message { + if (typeof val !== 'object' || !val) { + return false; + } + + const roleSet = new Set(['system', 'user', 'tool', 'assistant']); + + return 'role' in val && typeof val.role === 'string' && roleSet.has(val.role); +} + +export function isMessageArr(val: unknown): val is Message[] { + return Array.isArray(val) && val.every(it => isMessage(it)); +} + +export function isContentPart(val: unknown): val is ContentPart { + if ( + typeof val !== 'object' || + !val || + !('type' in val) || + typeof val.type !== 'string' + ) { + return false; + } + + if (val.type === 'text') { + return 'text' in val && typeof val.text === 'string'; + } + + if (val.type === 'image_url') { + return Boolean( + 'image_url' in val && + typeof val.image_url === 'object' && + val.image_url && + 'url' in val.image_url && + typeof val.image_url.url === 'string', + ); + } + + return false; +} + +export function isContentPartArr(val: unknown): val is ContentPart[] { + return Array.isArray(val) && val.every(it => isContentPart(it)); +} diff --git a/packages/cozeloop-ai/src/prompt/hub.ts b/packages/cozeloop-ai/src/prompt/hub.ts index 2c7b749..331613e 100644 --- a/packages/cozeloop-ai/src/prompt/hub.ts +++ b/packages/cozeloop-ai/src/prompt/hub.ts @@ -20,8 +20,8 @@ import { export class PromptHub { private _options: PromptHubOptions; - private readonly _cache: PromptCache; private _api: PromptApi; + private readonly _cache: PromptCache; /** Prompt cache instance */ get cache() { @@ -37,9 +37,7 @@ export class PromptHub { }); this._options = mergeConfig(options, { workspaceId }); - this._api = new PromptApi(this._options.apiClient); - this._cache = new PromptCache(options.cacheOptions || {}, this._api); this._cache.startPollingUpdate(workspaceId); } diff --git a/packages/cozeloop-ai/src/prompt/index.ts b/packages/cozeloop-ai/src/prompt/index.ts index 68b960b..b8366a6 100644 --- a/packages/cozeloop-ai/src/prompt/index.ts +++ b/packages/cozeloop-ai/src/prompt/index.ts @@ -1,12 +1,17 @@ // Copyright (c) 2025 Bytedance Ltd. and/or its affiliates // SPDX-License-Identifier: MIT export { PromptHub } from './hub'; +export { PromptAsAService } from './ptaas'; export type { PromptHubOptions, PromptCacheOptions, PromptVariables, + PromptVariableMap, + PromptExecuteOptions, + PromptAsAServiceOptions, Message, ContentPart, ContentPartImage, ContentPartText, + ToolCall, } from './types'; diff --git a/packages/cozeloop-ai/src/prompt/ptaas.ts b/packages/cozeloop-ai/src/prompt/ptaas.ts new file mode 100644 index 0000000..36a4ea3 --- /dev/null +++ b/packages/cozeloop-ai/src/prompt/ptaas.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT +import { ensureProperty, EnvKeys } from '../utils/env'; +import { mergeConfig } from '../utils/common'; +import { type ExecutePromptReq, PromptApi } from '../api'; +import { + type PromptExecuteOptions, + type PromptAsAServiceOptions, +} from './types'; +import { toVariableVals, toLoopMessages } from './converter'; + +export class PromptAsAService { + private _options: PromptAsAServiceOptions; + private _api: PromptApi; + + constructor(options: PromptAsAServiceOptions) { + const workspaceId = ensureProperty({ + propName: 'workspaceId', + envKey: EnvKeys.WORKSPACE_ID, + value: options.workspaceId, + tag: 'PromptService', + }); + this._options = mergeConfig(options, { + workspaceId, + }); + this._api = new PromptApi(this._options.apiClient); + } + + private _getExecuteReq(options: PromptExecuteOptions) { + // const { messages, variables, prompt } = options; + const prompt = options.prompt ?? this._options.prompt; + if (typeof prompt === 'undefined') { + throw new Error('[PromptAsAService] Prompt is unprovided'); + } + + const req: ExecutePromptReq = { + workspace_id: this._options.workspaceId, + prompt_identifier: prompt, + variable_vals: toVariableVals(options.variables), + messages: toLoopMessages(options.messages), + }; + + return req; + } + + /** + * Invoke prompt-as-a-service + * + * @param options {@link PromptExecuteOptions} + * + * @example + * ```typescript + * + * // 1. invoke with messages + * const model = new PromptAsAService({ + * workspaceId: 'your_workspace_id', + * prompt: { + * prompt_key: 'your_prompt_key', + * // version: '0.0.1', + * }, + * apiClient: { + * token: 'pat_xxx', + * }, + * }); + * + * const reply = await model.invoke({ + * messages: [ + * { role: 'user', content: 'hi' }, + * ], + * }); + * + * // 2. invoke, and specify prompt at runtime + * const model = new PromptAsAService({}); + * + * const reply = await model.invoke({ + * // higher priority than PromptAsAService constructor params + * prompt: { + * prompt_key: 'your_prompt_key', + * // version: '0.0.1', + * }, + * messages: [ + * { role: 'user', content: 'hi' }, + * ], + * }); + * ``` + */ + async invoke(options: PromptExecuteOptions) { + const req = this._getExecuteReq(options); + const resp = await this._api.executePrompt(req); + + return resp.data; + } + + /** + * Streaming-invoke prompt-as-a-service + * + * @param options {@link PromptExecuteOptions} + * + * @example + * ```typescript + * + * // 1. stream with messages + * const model = new PromptAsAService({}); + * + * const replyStream = await model.invoke({ + * messages: [ + * { role: 'user', content: 'hi' }, + * ], + * }); + * + * for await (const chunk of replyStream) { + * // chunk is {@link PromptExecuteOptions} + * } + * + * ``` + */ + async stream(options: PromptExecuteOptions) { + const req = this._getExecuteReq(options); + const resp = await this._api.streamingExecutePrompt(req); + + return resp; + } +} diff --git a/packages/cozeloop-ai/src/prompt/trace.ts b/packages/cozeloop-ai/src/prompt/trace.ts index 965afe7..ba3365c 100644 --- a/packages/cozeloop-ai/src/prompt/trace.ts +++ b/packages/cozeloop-ai/src/prompt/trace.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 Bytedance Ltd. and/or its affiliates // SPDX-License-Identifier: MIT import { serializeTagValue } from '../tracer/utils'; -import { type PromptQuery, type TemplateMessage } from '../api'; +import { type PromptQuery, type LoopMessage } from '../api'; import { type Message, type PromptVariables } from './types'; export function toPromptHubInput({ prompt_key, version, label }: PromptQuery) { @@ -13,7 +13,7 @@ export function toPromptHubInput({ prompt_key, version, label }: PromptQuery) { } export function toPromptTemplateInput( - messages?: TemplateMessage[], + messages?: LoopMessage[], variables?: PromptVariables, ) { return serializeTagValue({ diff --git a/packages/cozeloop-ai/src/prompt/types.ts b/packages/cozeloop-ai/src/prompt/types.ts index 8059cd4..a5705ca 100644 --- a/packages/cozeloop-ai/src/prompt/types.ts +++ b/packages/cozeloop-ai/src/prompt/types.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT import { type SimpleLogger } from '../utils/logger'; import { + type PromptQuery, type ApiClient, type ApiClientOptions, type VariableDef, @@ -14,12 +15,6 @@ export interface PromptCacheOptions { maxSize?: number; } -export interface Message { - role: 'system' | 'user' | 'assistant'; - content: string; - parts?: (ContentPartText | ContentPartImage)[]; -} - export interface ContentPartText { type: 'text'; text: string; @@ -35,6 +30,24 @@ export interface ContentPartImage { export type ContentPart = ContentPartText | ContentPartImage; +export interface ToolCall { + id?: string; + type?: 'function'; + function: { + name?: string; + arguments?: string; + }; +} + +export interface Message { + role: 'system' | 'user' | 'tool' | 'assistant'; + content?: string; + parts?: (ContentPartText | ContentPartImage)[]; + reasoning_content?: string; + tool_call_id?: string; + tool_calls?: ToolCall[]; +} + export interface PromptHubOptions { /** Workspace ID, use process.env.COZELOOP_WORKSPACE_ID when unprovided */ workspaceId?: string; @@ -67,3 +80,25 @@ export type PromptVariableMap = Record< | { def: VariableDef; value?: PromptVariables[keyof PromptVariables] } | undefined >; + +export interface PromptAsAServiceOptions { + /** Workspace ID, use process.env.COZELOOP_WORKSPACE_ID when unprovided */ + workspaceId?: string; + /** The Loop {@link ApiClient} instance or {@link ApiClientOptions} */ + apiClient?: ApiClient | ApiClientOptions; + /** Prompt identified by prompt query */ + prompt?: PromptQuery; + /** Enable trace report for `invoke` and `stream` */ + // traceable?: boolean; + /** A logger function to print debug message */ + logger?: SimpleLogger; +} + +export interface PromptExecuteOptions { + /** Prompt identified by prompt query */ + prompt?: PromptQuery; + /** Messages */ + messages: Message[]; + /** variable values of Prompt */ + variables?: PromptVariables; +} diff --git a/packages/cozeloop-ai/src/prompt/utils.ts b/packages/cozeloop-ai/src/prompt/utils.ts index 7878920..f63f766 100644 --- a/packages/cozeloop-ai/src/prompt/utils.ts +++ b/packages/cozeloop-ai/src/prompt/utils.ts @@ -4,11 +4,11 @@ import nj from 'nunjucks'; import { stringifyVal } from '../utils/common'; import type { - TemplateMessage, + LoopMessage, + LoopContentPart, PromptQuery, PromptTemplate, VariableDef, - TemplateContentPart, } from '../api'; import { type Message, @@ -94,15 +94,15 @@ export function formatPromptTemplate( } function formatPart( - part: TemplateContentPart, + part: LoopContentPart, variableMap: PromptVariableMap, formatText: (content: string) => string, ): ContentPart[] { switch (part.type) { case 'text': - return [{ type: 'text', text: formatText(part.text) }]; + return [{ type: 'text', text: formatText(part.text || '') }]; case 'multi_part_variable': { - const variable = variableMap[part.text]; + const variable = variableMap[part.text ?? '']; if (!variable?.value) { return []; } @@ -123,7 +123,7 @@ function formatPart( } function formatParts( - parts: TemplateContentPart[], + parts: LoopContentPart[], variableMap: PromptVariableMap, formatText: (content: string) => string, ) { @@ -140,7 +140,7 @@ function formatParts( } function formatMessage( - message: TemplateMessage, + message: LoopMessage, variableMap: PromptVariableMap, formatText: (content: string) => string, ): Message[] { diff --git a/packages/cozeloop-ai/tsup.config.ts b/packages/cozeloop-ai/tsup.config.ts index c65befa..5049396 100644 --- a/packages/cozeloop-ai/tsup.config.ts +++ b/packages/cozeloop-ai/tsup.config.ts @@ -16,7 +16,11 @@ export default defineConfig(() => { tsconfig: './tsconfig.build.json', format: ['cjs', 'esm'], dts: false, - onSuccess: 'tsc -b ./tsconfig.typings.json', + onSuccess: [ + 'printf "TSC Start build \x1b[1mtypings\x1b[0m"', + 'tsc -b ./tsconfig.typings.json', + 'printf " ✅\n"', + ].join(' && '), env: { COZELOOP_VERSION: packageJson.version, },