diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 15e567a0e..b93fb1b58 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "tsx watch src/server", "build": "tsup --config tsup.config.ts && pnpm run sentry:sourcemaps", - "dev:debug": "tsx watch --inspect-brk src/server", + "dev:debug": "tsx watch --inspect src/server", "lint": "eslint src/", "prettier": "prettier --write \"**/*.{ts,tsx,md}\"", "tc": "tsc --noEmit", @@ -38,7 +38,7 @@ "@types/node": "^22.5.1", "@types/uuid": "^10.0.0", "tsup": "^8.2.4", - "tsx": "^4.16.2", + "tsx": "^4.19.2", "vitest": "^2.0.4" } } diff --git a/apps/web/src/app/(private)/evaluations/(evaluation)/[evaluationUuid]/editor/_components/EvaluationEditor/Editor/Playground/Preview/index.tsx b/apps/web/src/app/(private)/evaluations/(evaluation)/[evaluationUuid]/editor/_components/EvaluationEditor/Editor/Playground/Preview/index.tsx index 892794e2c..e10e7f4dd 100644 --- a/apps/web/src/app/(private)/evaluations/(evaluation)/[evaluationUuid]/editor/_components/EvaluationEditor/Editor/Playground/Preview/index.tsx +++ b/apps/web/src/app/(private)/evaluations/(evaluation)/[evaluationUuid]/editor/_components/EvaluationEditor/Editor/Playground/Preview/index.tsx @@ -77,6 +77,7 @@ export default function Preview({ {error || (metadata?.errors.length ?? 0) > 0 ? ( Run prompt diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/Editor/Playground/Preview.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/Editor/Playground/Preview.tsx index 548c39309..59ab892c0 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/Editor/Playground/Preview.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/Editor/Playground/Preview.tsx @@ -10,6 +10,7 @@ import { AppliedRules, applyCustomRules, LATITUDE_DOCS_URL, + ProviderRules, } from '@latitude-data/core/browser' import { Alert, @@ -27,6 +28,34 @@ import { ROUTES } from '$/services/routes' import useProviderApiKeys from '$/stores/providerApiKeys' import Link from 'next/link' +function WarningLink({ providerRule }: { providerRule: ProviderRules }) { + return ( + + + Learn more + + + ) +} + +function Warnings({ warnings }: { warnings: AppliedRules }) { + const rules = warnings.rules + if (!rules.length) return null + + return rules.map((rule, index) => ( + } + /> + )) +} + export default function Preview({ metadata, parameters, @@ -91,6 +120,7 @@ export default function Preview({ const rule = applyCustomRules({ providerType: provider.provider, messages: conversation.messages, + config: conversation.config, }) setFixedMessages(rule?.messages ?? conversation.messages) @@ -99,7 +129,7 @@ export default function Preview({ return (
- + {warningRule ? : null}
0 ? ( Run prompt @@ -158,32 +189,3 @@ export default function Preview({
) } - -function WarningMessage({ rule }: { rule: AppliedRules | undefined }) { - if (!rule) return null - - switch (rule.rule) { - case 'AnthropicMultipleSystemMessagesUnsupported': - return ( - - - Learn more - - - } - /> - ) - case 'GoogleSingleStartingSystemMessageSupported': - return - default: - return null - } -} diff --git a/docs/assets/provider_rules_1.png b/docs/assets/provider_rules_1.png index 059b65dfa..0b59b91b7 100644 Binary files a/docs/assets/provider_rules_1.png and b/docs/assets/provider_rules_1.png differ diff --git a/docs/guides/getting-started/providers.mdx b/docs/guides/getting-started/providers.mdx index 197c48254..952133d45 100644 --- a/docs/guides/getting-started/providers.mdx +++ b/docs/guides/getting-started/providers.mdx @@ -1,11 +1,11 @@ --- -title: Providers +title: Setup Providers description: Learn how to add providers into Latitude to use it in your prompts. --- ## Overview -Providers in Latitude are the foundation for connecting to various AI models and services. +Providers in Latitude are the foundation for connecting to various AI models and services. The name you introduce for each provider will be the one you will have to use in your prompts. diff --git a/docs/guides/getting-started/providers/anthropic.mdx b/docs/guides/getting-started/providers/anthropic.mdx new file mode 100644 index 000000000..5d35ec790 --- /dev/null +++ b/docs/guides/getting-started/providers/anthropic.mdx @@ -0,0 +1,43 @@ +--- +title: Anthropic +description: Common questions about Anthropic provider +--- + +## How do I use the Anthropic cache? + +[Anthropic cache](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching) allows you to cache parts of a prompt. As explained in their documentation, you need to opt-in within the prompt to start caching parts of it. + +To do this, add `cacheControl: true` to the front matter of your prompt. + +```markdown +--- +provider: name-of-your-antropic-provider-in-latitude +model: claude-3-5-sonnet-20241022 +cacheControl: true +--- +``` + +Once this is set up, you can start caching specific parts of the prompt: + +``` +This part of the text is not cached + + Read this large book and answer users' questions. + + ...BIG_BOOK_CONTENT... + + +```` + +If you want an entire message to be cached, add the cache directive to the `user`, `assistant`, or `system` tags: + +``` + + This text will be cached. + + This text will also be cached. + + And this text as well. + +``` + diff --git a/docs/guides/prompt-manager/custom-rules.mdx b/docs/guides/prompt-manager/custom-rules.mdx index 98048ddc7..836e724ea 100644 --- a/docs/guides/prompt-manager/custom-rules.mdx +++ b/docs/guides/prompt-manager/custom-rules.mdx @@ -7,27 +7,6 @@ description: Some providers have specific rules that you need to follow when usi Some providers have specific rules that you need to follow when using them in your prompts. These rules are called "custom rules" and are enforced by the Latitude engine. -## Anthropic - -Anthropic only supports system messages at the beginning of the conversation. All other system messages get converted to user messages. - -![](/assets/provider_rules_1.png) - -You can add the system message via a property in the front matter of your prompt: - -``` ---- -provider: Anthropic -model: claude-3-5-sonnet-latest -system: | - This is a multi-line system prompt - that spans multiple lines ---- - -... - -``` - -## Google - -Google only supports system messages at the beginning of the conversation. All other system messages are converted to user messages. +## Provider rules +- [Anthropic](/guides/prompt-manager/provider-rules/anthropic) +- [Google](/guides/prompt-manager/provider-rules/google) diff --git a/docs/guides/prompt-manager/provider-rules/anthropic.mdx b/docs/guides/prompt-manager/provider-rules/anthropic.mdx new file mode 100644 index 000000000..0f2561edf --- /dev/null +++ b/docs/guides/prompt-manager/provider-rules/anthropic.mdx @@ -0,0 +1,75 @@ +--- +title: Anthropic provider rules +description: Learn about the rules you need to follow when using the Anthropic provider in Latitude. +--- + +1. [System messages must be followed by at least by another message](#rule-1-not-only-system-messages) +2. [No system messages are allowed after an assistant or user message](#rule-2-no-system-messages-after-assistant-or-user-messages) + +### System messages must be followed by at least another message + +Anthropic does not consider system messages as part of the list of general messages and thus, if you don't add at least a user or assistant message, the list of messages sent to anthropic would be empty, which would result in an error. + +```json +{ + "system": [ + { + "type": "text", + "text": "You are an AI assistant tasked with analyzing literary works. Your goal is to provide insightful commentary on themes, characters, and writing style.\n" + }, + { + "type": "text", + "text": "", + "cache_control": { "type": "ephemeral" } + } + ], + "messages": [] // this is invalid +} +``` + +So, the following prompt in Latitude is invalid for an Anthropic provider: + +``` +--- +provider: Anthropic +model: claude-3-5-sonnet-latest +--- + +This is a system message +``` + +This would generate the following warning: + +![](/assets/provider_rules_1.png) + +Instead, add at least another message wrapped in a `` or `` tag: + +``` +--- +provider: Anthropic +model: claude-3-5-sonnet-latest +--- + +This is a system message + +This is a user message +``` + +### No system messages are allowed after an assistant or user message + +Any system message added after an assistant or user message will be automatically converted into a user message: + +``` +--- +provider: Anthropic +model: claude-3-5-sonnet-latest +--- + +This is a system message + +This is a user message + +This is another system message /* This message will be converted to a user message */ +``` + +This is because Anthropic does not consider system messages as part of the list of general messages and thus they can't be concatenated with other messages. diff --git a/docs/guides/prompt-manager/provider-rules/google.mdx b/docs/guides/prompt-manager/provider-rules/google.mdx new file mode 100644 index 000000000..5652b8ac0 --- /dev/null +++ b/docs/guides/prompt-manager/provider-rules/google.mdx @@ -0,0 +1,23 @@ +--- +title: Google provider rules +description: Learn about the rules you need to follow when using the Google provider in Latitude. +--- + +1. [System messages must be at the beginning of the conversation](#rule-1-system-messages-must-be-at-the-beginning-of-the-conversation) + +### System messages must be at the beginning of the conversation + +Google only supports system messages at the beginning of the conversation. All other system messages are converted to user messages. + +``` +--- +provider: Google +model: gemini-1.5-flash +--- + +This is a system message + +This is a user message + +This is another system message /* This message will be converted to a user message */ +``` diff --git a/docs/mint.json b/docs/mint.json index e407f4d76..c4aa802fd 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -33,7 +33,13 @@ "guides/getting-started/introduction", "guides/getting-started/concepts", "guides/getting-started/quick-start", - "guides/getting-started/providers", + { + "group": "Providers", + "pages": [ + "guides/getting-started/providers", + "guides/getting-started/providers/anthropic" + ] + }, "guides/getting-started/invite-your-team" ] }, @@ -50,7 +56,14 @@ "guides/prompt-manager/version-control", "guides/prompt-manager/json-output", "guides/prompt-manager/tools", - "guides/prompt-manager/custom-rules", + { + "group": "Custom Rules", + "pages": [ + "guides/prompt-manager/custom-rules", + "guides/prompt-manager/provider-rules/anthropic", + "guides/prompt-manager/provider-rules/google" + ] + }, "guides/prompt-manager/cache" ] }, @@ -66,10 +79,7 @@ }, { "group": "Logs", - "pages": [ - "guides/logs/overview", - "guides/logs/upload-logs" - ] + "pages": ["guides/logs/overview", "guides/logs/upload-logs"] }, { "group": "Datasets", diff --git a/packages/compiler/src/compiler/base/nodes/tags/content.ts b/packages/compiler/src/compiler/base/nodes/tags/content.ts index 98e75086c..b52e28e53 100644 --- a/packages/compiler/src/compiler/base/nodes/tags/content.ts +++ b/packages/compiler/src/compiler/base/nodes/tags/content.ts @@ -1,3 +1,4 @@ +import { removeCommonIndent } from '$compiler/compiler/utils' import errors from '$compiler/error/errors' import { ContentTag } from '$compiler/parser/interfaces' import { ContentType } from '$compiler/types' @@ -15,7 +16,7 @@ export async function compile( popStrayText, addContent, }: CompileNodeContext, - _: Record, + attributes: Record, ) { if (isInsideContentTag) { baseNodeError(errors.contentTagInsideContent, node) @@ -29,17 +30,19 @@ export async function compile( isInsideContentTag: true, }) } - const textContent = popStrayText() + const textContent = removeCommonIndent(popStrayText()) // TODO: This if else is probably not required but the types enforce it. // Improve types. if (node.name === 'text') { addContent({ + ...attributes, type: ContentType.text, text: textContent, }) } else { addContent({ + ...attributes, type: ContentType.image, image: textContent, }) diff --git a/packages/compiler/src/compiler/base/nodes/tags/message.test.ts b/packages/compiler/src/compiler/base/nodes/tags/message.test.ts new file mode 100644 index 000000000..2750f456a --- /dev/null +++ b/packages/compiler/src/compiler/base/nodes/tags/message.test.ts @@ -0,0 +1,283 @@ +import { render } from '$compiler/compiler' +import { getExpectedError } from '$compiler/compiler/test/helpers' +import { removeCommonIndent } from '$compiler/compiler/utils' +import { CUSTOM_TAG_END, CUSTOM_TAG_START } from '$compiler/constants' +import CompileError from '$compiler/error/error' +import { + AssistantMessage, + ImageContent, + SystemMessage, + TextContent, + UserMessage, +} from '$compiler/types' +import { describe, expect, it } from 'vitest' + +describe('messages', async () => { + it('allows creating system, user, assistant', async () => { + const prompt = ` + system message + user message + assistant message + ` + const result = await render({ + prompt: removeCommonIndent(prompt), + parameters: {}, + }) + + expect(result.messages.length).toBe(3) + const systemMessage = result.messages[0]! + const userMessage = result.messages[1]! as UserMessage + const assistantMessage = result.messages[2]! as AssistantMessage + + expect(systemMessage.role).toBe('system') + expect(systemMessage.content).toEqual([ + { + type: 'text', + text: 'system message', + }, + ]) + + expect(userMessage.role).toBe('user') + expect((userMessage.content[0]! as TextContent).text).toBe('user message') + + expect(assistantMessage.role).toBe('assistant') + expect(assistantMessage.content).toEqual([ + { + type: 'text', + text: 'assistant message', + }, + ]) + }) + + it('fails when using an unknown tag', async () => { + const prompt = ` + message + ` + const action = () => + render({ + prompt: removeCommonIndent(prompt), + parameters: {}, + }) + const error = await getExpectedError(action, CompileError) + expect(error.code).toBe('unknown-tag') + }) + + it('can create messages with the common message tag', async () => { + const prompt = ` + message + ` + const result1 = await render({ + prompt: removeCommonIndent(prompt), + parameters: { + role: 'system', + }, + }) + const result2 = await render({ + prompt: removeCommonIndent(prompt), + parameters: { + role: 'user', + }, + }) + + expect(result1.messages.length).toBe(1) + const message1 = result1.messages[0]! + expect(message1.role).toBe('system') + expect(message1.content).toEqual([{ type: 'text', text: 'message' }]) + + expect(result2.messages.length).toBe(1) + const message2 = result2.messages[0]! + expect(message2.role).toBe('user') + expect((message2.content[0] as TextContent)!.text).toBe('message') + }) + + it('raises an error when using an invalid message role', async () => { + const prompt = ` + message + ` + const action = () => + render({ + prompt: removeCommonIndent(prompt), + parameters: {}, + }) + const error = await getExpectedError(action, CompileError) + expect(error.code).toBe('invalid-message-role') + }) + + it('throws an error when a message tag is inside another message', async () => { + const prompt = ` + + user message + + ` + const action = () => + render({ + prompt: removeCommonIndent(prompt), + parameters: {}, + }) + const error = await getExpectedError(action, CompileError) + expect(error.code).toBe('message-tag-inside-message') + }) + + it('creates a system message when no message tag is present', async () => { + const prompt = ` + Test message + user message + ` + const result = await render({ + prompt: removeCommonIndent(prompt), + parameters: {}, + }) + + expect(result.messages.length).toBe(2) + const systemMessage = result.messages[0]! as SystemMessage + const userMessage = result.messages[1]! as UserMessage + + expect(systemMessage.role).toBe('system') + expect(systemMessage.content).toEqual([ + { + type: 'text', + text: 'Test message', + }, + ]) + + expect(userMessage.role).toBe('user') + expect((userMessage.content[0]! as TextContent).text).toBe('user message') + }) + + it('allows message tag to have extra attributes', async () => { + const prompt = ` + User message + Assistant message + System message + ` + const result = await render({ + prompt: removeCommonIndent(prompt), + parameters: {}, + }) + + expect(result.messages).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'User message' }], + foo: 'user_bar', + name: undefined, + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Assistant message', + }, + ], + foo: 'assistant_bar', + toolCalls: [], + }, + { + role: 'system', + content: [ + { + type: 'text', + text: 'System message', + }, + ], + foo: 'system_bar', + }, + ]) + }) +}) + +describe('message contents', async () => { + it('all messages can have multiple content tags', async () => { + const prompt = ` + + text content + image content + another text content + + ` + const result = await render({ + prompt: removeCommonIndent(prompt), + parameters: {}, + }) + + expect(result.messages.length).toBe(1) + const message = result.messages[0]! as UserMessage + expect(message.content.length).toBe(3) + expect(message.content[0]!.type).toBe('text') + expect((message.content[0]! as TextContent).text).toBe('text content') + + expect(message.content[1]!.type).toBe('image') + expect((message.content[1]! as ImageContent).image).toBe('image content') + + expect(message.content[2]!.type).toBe('text') + expect((message.content[2]! as TextContent).text).toBe( + 'another text content', + ) + }) + + it('fails when using an invalid content type', async () => { + const prompt = ` + + text content + + ` + const action = () => + render({ + prompt: removeCommonIndent(prompt), + parameters: {}, + }) + const error = await getExpectedError(action, CompileError) + expect(error.code).toBe('unknown-tag') + }) + + it('creates a text content when no content tag is present', async () => { + const prompt = ` + + Test message + + ` + const result = await render({ + prompt: removeCommonIndent(prompt), + parameters: {}, + }) + + expect(result.messages.length).toBe(1) + const message = result.messages[0]! + + expect(message.content).toEqual([ + { + type: 'text', + text: 'Test message', + }, + ]) + }) + + it('allows content tag to have extra attributes', async () => { + const prompt = ` + + + Long text cached... + + Short text not cached + + ` + const result = await render({ + prompt: removeCommonIndent(prompt), + parameters: {}, + }) + expect(result.messages.length).toBe(1) + const message = result.messages[0]! + expect(message.content).toEqual([ + { + type: 'text', + text: 'Long text cached...', + cache_control: { type: 'ephimeral' }, + }, + { + type: 'text', + text: 'Short text not cached', + }, + ]) + }) +}) diff --git a/packages/compiler/src/compiler/base/nodes/tags/message.ts b/packages/compiler/src/compiler/base/nodes/tags/message.ts index 789d4b8c1..8ca081d32 100644 --- a/packages/compiler/src/compiler/base/nodes/tags/message.ts +++ b/packages/compiler/src/compiler/base/nodes/tags/message.ts @@ -11,7 +11,6 @@ import { MessageContent, MessageRole, SystemMessage, - TextContent, ToolMessage, UserMessage, } from '$compiler/types' @@ -92,13 +91,15 @@ function buildMessage( if (role === MessageRole.system) { return { + ...attributes, role, - content: (content[0] as TextContent)?.text ?? '', + content, } as SystemMessage } if (role === MessageRole.user) { return { + ...attributes, role, name: attributes.name ? String(attributes.name) : undefined, content, @@ -107,9 +108,10 @@ function buildMessage( if (role === MessageRole.assistant) { return { + ...attributes, role, toolCalls: toolCalls.map(({ value }) => value), - content: (content[0]! as TextContent).text, + content, } as AssistantMessage } diff --git a/packages/compiler/src/compiler/chain.test.ts b/packages/compiler/src/compiler/chain.test.ts index 77ca49306..6fa1c613d 100644 --- a/packages/compiler/src/compiler/chain.test.ts +++ b/packages/compiler/src/compiler/chain.test.ts @@ -101,7 +101,12 @@ describe('chain', async () => { const systemMessage = conversation.messages[0]! expect(systemMessage.role).toBe('system') - expect(systemMessage.content).toBe('System message') + expect(systemMessage.content).toEqual([ + { + type: 'text', + text: 'System message', + }, + ]) const userMessage = conversation.messages[1]! as UserMessage expect(userMessage.role).toBe('user') @@ -123,7 +128,12 @@ describe('chain', async () => { const assistantMessage = conversation.messages[4]! as AssistantMessage expect(assistantMessage.role).toBe('assistant') - expect(assistantMessage.content).toBe('Assistant message: foo') + expect(assistantMessage.content).toEqual([ + { + type: 'text', + text: 'Assistant message: foo', + }, + ]) }) it('stops at a step tag', async () => { @@ -145,16 +155,40 @@ describe('chain', async () => { expect(completed1).toBe(false) expect(conversation1.messages.length).toBe(1) - expect(conversation1.messages[0]!.content).toBe('Message 1') + expect(conversation1.messages[0]).toEqual({ + role: MessageRole.system, + content: [ + { + type: 'text', + text: 'Message 1', + }, + ], + }) const { completed: completed2, conversation: conversation2 } = await chain.step('response') expect(completed2).toBe(true) expect(conversation2.messages.length).toBe(3) - expect(conversation2.messages[0]!.content).toBe('Message 1') + expect(conversation2.messages[0]).toEqual({ + role: MessageRole.system, + content: [ + { + type: 'text', + text: 'Message 1', + }, + ], + }) expect(conversation2.messages[1]!.content).toBe('response') - expect(conversation2.messages[2]!.content).toBe('Message 2') + expect(conversation2.messages[2]!).toEqual({ + role: MessageRole.system, + content: [ + { + type: 'text', + text: 'Message 2', + }, + ], + }) }) it('fails when an assistant message is not provided in followup steps', async () => { @@ -239,9 +273,29 @@ describe('chain', async () => { }) const conversation = await complete({ chain }) - expect(conversation.messages[0]!.content).toBe('1') - expect(conversation.messages[1]!.content).toBe('') - expect(conversation.messages[2]!.content).toBe('2') + expect(conversation.messages[0]!).toEqual({ + role: MessageRole.system, + content: [ + { + type: 'text', + text: '1', + }, + ], + }) + expect(conversation.messages[1]).toEqual({ + role: MessageRole.assistant, + toolCalls: [], + content: '', + }) + expect(conversation.messages[2]).toEqual({ + role: MessageRole.system, + content: [ + { + type: 'text', + text: '2', + }, + ], + }) expect(func1).toHaveBeenCalledTimes(1) expect(func2).toHaveBeenCalledTimes(1) }) @@ -265,9 +319,15 @@ describe('chain', async () => { }) const conversation = await complete({ chain }) - expect( - conversation.messages[conversation.messages.length - 1]!.content, - ).toBe('6') + expect(conversation.messages[conversation.messages.length - 1]).toEqual({ + role: MessageRole.system, + content: [ + { + type: 'text', + text: '6', + }, + ], + }) }) it('maintains the scope in if statements', async () => { @@ -299,9 +359,15 @@ describe('chain', async () => { }) const conversation = await complete({ chain: correctChain }) - expect( - conversation.messages[conversation.messages.length - 1]!.content, - ).toBe('6') + expect(conversation.messages[conversation.messages.length - 1]!).toEqual({ + role: MessageRole.system, + content: [ + { + type: 'text', + text: '6', + }, + ], + }) const incorrectChain = new Chain({ prompt: incorrectPrompt, @@ -346,7 +412,15 @@ describe('chain', async () => { expect((conversation.messages[4]!.content[0]! as TextContent).text).toBe( '2', ) - expect(conversation.messages[6]!.content).toBe('3') + expect(conversation.messages[6]).toEqual({ + role: MessageRole.system, + content: [ + { + type: 'text', + text: '3', + }, + ], + }) }) it('cannot access variables created in a loop outside its scope', async () => { @@ -414,9 +488,16 @@ describe('chain', async () => { 3.3 `), ) - expect( - conversation.messages[conversation.messages.length - 1]!.content, - ).toBe('9') + + expect(conversation.messages[conversation.messages.length - 1]).toEqual({ + role: MessageRole.system, + content: [ + { + type: 'text', + text: '9', + }, + ], + }) }) it('saves the response in a variable', async () => { @@ -436,7 +517,12 @@ describe('chain', async () => { expect(conversation.messages.length).toBe(2) expect(conversation.messages[0]!.content).toBe('foo') - expect(conversation.messages[1]!.content).toBe('foo') + expect(conversation.messages[1]!.content).toEqual([ + { + type: 'text', + text: 'foo', + }, + ]) }) it('returns the correct configuration in all steps', async () => { diff --git a/packages/compiler/src/compiler/compile.test.ts b/packages/compiler/src/compiler/compile.test.ts index fdec8cdfb..255923b10 100644 --- a/packages/compiler/src/compiler/compile.test.ts +++ b/packages/compiler/src/compiler/compile.test.ts @@ -1,11 +1,11 @@ +import { getExpectedError } from '$compiler/compiler/test/helpers' import { CUSTOM_TAG_END, CUSTOM_TAG_START } from '$compiler/constants' import CompileError from '$compiler/error/error' import { AssistantMessage, - ImageContent, Message, MessageContent, - SystemMessage, + MessageRole, TextContent, UserMessage, } from '$compiler/types' @@ -14,19 +14,6 @@ import { describe, expect, it, vi } from 'vitest' import { render } from '.' import { removeCommonIndent } from './utils' -const getExpectedError = async ( - action: () => Promise, - errorClass: new () => T, -): Promise => { - try { - await action() - } catch (err) { - expect(err).toBeInstanceOf(errorClass) - return err as T - } - throw new Error('Expected an error to be thrown') -} - async function getCompiledText( prompt: string, parameters: Record = {}, @@ -86,8 +73,15 @@ describe('comments', async () => { expect(result.messages.length).toBe(1) const message = result.messages[0]! - const text = message.content - expect(text).toBe('anna\nbob\n\ncharlie') + expect(message).toEqual({ + role: MessageRole.system, + content: [ + { + type: 'text', + text: 'anna\nbob\n\ncharlie', + }, + ], + }) }) it('also allows using tag comments', async () => { @@ -105,188 +99,12 @@ describe('comments', async () => { expect(result.messages.length).toBe(1) const message = result.messages[0]! - expect(message.content).toBe('Test message') - }) -}) - -describe('messages', async () => { - it('allows creating system, user, assistant', async () => { - const prompt = ` - system message - user message - assistant message - ` - const result = await render({ - prompt: removeCommonIndent(prompt), - parameters: {}, - }) - - expect(result.messages.length).toBe(3) - const systemMessage = result.messages[0]! - const userMessage = result.messages[1]! as UserMessage - const assistantMessage = result.messages[2]! as AssistantMessage - - expect(systemMessage.role).toBe('system') - expect(systemMessage.content).toBe('system message') - - expect(userMessage.role).toBe('user') - expect((userMessage.content[0]! as TextContent).text).toBe('user message') - - expect(assistantMessage.role).toBe('assistant') - expect(assistantMessage.content).toBe('assistant message') - }) - - it('fails when using an unknown tag', async () => { - const prompt = ` - message - ` - const action = () => - render({ - prompt: removeCommonIndent(prompt), - parameters: {}, - }) - const error = await getExpectedError(action, CompileError) - expect(error.code).toBe('unknown-tag') - }) - - it('can create messages with the common message tag', async () => { - const prompt = ` - message - ` - const result1 = await render({ - prompt: removeCommonIndent(prompt), - parameters: { - role: 'system', - }, - }) - const result2 = await render({ - prompt: removeCommonIndent(prompt), - parameters: { - role: 'user', + expect(message.content).toEqual([ + { + type: 'text', + text: 'Test message', }, - }) - - expect(result1.messages.length).toBe(1) - const message1 = result1.messages[0]! - expect(message1.role).toBe('system') - expect(message1.content).toBe('message') - - expect(result2.messages.length).toBe(1) - const message2 = result2.messages[0]! - expect(message2.role).toBe('user') - expect((message2.content[0] as TextContent)!.text).toBe('message') - }) - - it('raises an error when using an invalid message role', async () => { - const prompt = ` - message - ` - const action = () => - render({ - prompt: removeCommonIndent(prompt), - parameters: {}, - }) - const error = await getExpectedError(action, CompileError) - expect(error.code).toBe('invalid-message-role') - }) - - it('throws an error when a message tag is inside another message', async () => { - const prompt = ` - - user message - - ` - const action = () => - render({ - prompt: removeCommonIndent(prompt), - parameters: {}, - }) - const error = await getExpectedError(action, CompileError) - expect(error.code).toBe('message-tag-inside-message') - }) - - it('creates a system message when no message tag is present', async () => { - const prompt = ` - Test message - user message - ` - const result = await render({ - prompt: removeCommonIndent(prompt), - parameters: {}, - }) - - expect(result.messages.length).toBe(2) - const systemMessage = result.messages[0]! as SystemMessage - const userMessage = result.messages[1]! as UserMessage - - expect(systemMessage.role).toBe('system') - expect(systemMessage.content).toBe('Test message') - - expect(userMessage.role).toBe('user') - expect((userMessage.content[0]! as TextContent).text).toBe('user message') - }) -}) - -describe('message contents', async () => { - it('all messages can have multiple content tags', async () => { - const prompt = ` - - text content - image content - another text content - - ` - const result = await render({ - prompt: removeCommonIndent(prompt), - parameters: {}, - }) - - expect(result.messages.length).toBe(1) - const message = result.messages[0]! as UserMessage - expect(message.content.length).toBe(3) - - expect(message.content[0]!.type).toBe('text') - expect((message.content[0]! as TextContent).text).toBe('text content') - - expect(message.content[1]!.type).toBe('image') - expect((message.content[1]! as ImageContent).image).toBe('image content') - - expect(message.content[2]!.type).toBe('text') - expect((message.content[2]! as TextContent).text).toBe( - 'another text content', - ) - }) - - it('fails when using an invalid content type', async () => { - const prompt = ` - - text content - - ` - const action = () => - render({ - prompt: removeCommonIndent(prompt), - parameters: {}, - }) - const error = await getExpectedError(action, CompileError) - expect(error.code).toBe('unknown-tag') - }) - - it('creates a text content when no content tag is present', async () => { - const prompt = ` - - Test message - - ` - const result = await render({ - prompt: removeCommonIndent(prompt), - parameters: {}, - }) - - expect(result.messages.length).toBe(1) - const message = result.messages[0]! - - expect(message.content).toBe('Test message') + ]) }) }) @@ -545,12 +363,12 @@ describe('conditional expressions', async () => { expect(message1.role).toBe('user') expect(message1.content.length).toBe(1) expect(message1.content[0]!.type).toBe('text') - expect((message1.content[0]! as TextContent).text).toBe('Foo!') + expect(message1.content).toEqual([{ type: 'text', text: 'Foo!' }]) expect(result2.messages.length).toBe(1) const message2 = result2.messages[0]! as AssistantMessage expect(message2.role).toBe('assistant') - expect(message2.content).toBe('Bar!') + expect(message2.content).toEqual([{ type: 'text', text: 'Bar!' }]) }) it('adds message contents conditionally', async () => { @@ -563,15 +381,40 @@ describe('conditional expressions', async () => { ${CUSTOM_TAG_START}/if${CUSTOM_TAG_END} ` - const result1 = await getCompiledText(prompt, { - foo: true, + + const result1 = await render({ + prompt: removeCommonIndent(prompt), + parameters: { foo: true }, }) - const result2 = await getCompiledText(prompt, { - foo: false, + const result2 = await render({ + prompt: removeCommonIndent(prompt), + parameters: { foo: false }, }) - expect(result1).toBe('Foo!') - expect(result2).toBe('Bar!') + expect(result1.messages).toEqual([ + { + role: MessageRole.user, + name: undefined, + content: [ + { + type: 'text', + text: 'Foo!', + }, + ], + }, + ]) + expect(result2.messages).toEqual([ + { + role: MessageRole.user, + name: undefined, + content: [ + { + type: 'text', + text: 'Bar!', + }, + ], + }, + ]) }) }) diff --git a/packages/compiler/src/compiler/compile.ts b/packages/compiler/src/compiler/compile.ts index 931feaee9..4e9115e10 100644 --- a/packages/compiler/src/compiler/compile.ts +++ b/packages/compiler/src/compiler/compile.ts @@ -13,7 +13,6 @@ import { MessageContent, MessageRole, SystemMessage, - TextContent, } from '$compiler/types' import type { Node as LogicalExpression } from 'estree' @@ -157,14 +156,14 @@ export class Compile { this.baseNodeError(errors.invalidToolCallPlacement, toolNode) }) - if (content.length > 0) { - const message = { - role: MessageRole.system, - content: (content[0] as TextContent).text, - } as SystemMessage + if (!content.length) return - this.addMessage(message) - } + const message = { + role: MessageRole.system, + content, + } as SystemMessage + + this.addMessage(message) } private popContent(): MessageContent[] { @@ -187,7 +186,7 @@ export class Compile { private popStepResponse() { if (this.stepResponse === undefined) return undefined - const response = { + const response: AssistantMessage = { role: MessageRole.assistant, content: this.stepResponse, toolCalls: [], @@ -195,7 +194,7 @@ export class Compile { this.stepResponse = undefined - return response as AssistantMessage + return response } private async resolveExpression( diff --git a/packages/compiler/src/compiler/test/helpers.ts b/packages/compiler/src/compiler/test/helpers.ts new file mode 100644 index 000000000..8cb896234 --- /dev/null +++ b/packages/compiler/src/compiler/test/helpers.ts @@ -0,0 +1,14 @@ +import { expect } from 'vitest' + +export async function getExpectedError( + action: () => Promise, + errorClass: new () => T, +): Promise { + try { + await action() + } catch (err) { + expect(err).toBeInstanceOf(errorClass) + return err as T + } + throw new Error('Expected an error to be thrown') +} diff --git a/packages/compiler/src/types/message.ts b/packages/compiler/src/types/message.ts index 14f4b3485..708db61e2 100644 --- a/packages/compiler/src/types/message.ts +++ b/packages/compiler/src/types/message.ts @@ -14,6 +14,7 @@ export enum MessageRole { interface IMessageContent { type: ContentType + [key: string]: unknown } export type TextContent = IMessageContent & { @@ -56,11 +57,11 @@ export type ToolCall = { interface IMessage { role: MessageRole content: MessageContent[] + [key: string]: unknown } -export type SystemMessage = { +export type SystemMessage = IMessage & { role: MessageRole.system - content: string } export type UserMessage = IMessage & { @@ -71,12 +72,13 @@ export type UserMessage = IMessage & { export type AssistantMessage = { role: MessageRole.assistant toolCalls: ToolCall[] - content: string | ToolRequestContent[] + content: string | ToolRequestContent[] | MessageContent[] } -export type ToolMessage = IMessage & { +export type ToolMessage = { role: MessageRole.tool content: ToolContent[] + [key: string]: unknown } export type Message = diff --git a/packages/core/src/services/ai/helpers.ts b/packages/core/src/services/ai/helpers.ts index 0ff8215f1..33681f1c8 100644 --- a/packages/core/src/services/ai/helpers.ts +++ b/packages/core/src/services/ai/helpers.ts @@ -48,6 +48,7 @@ export type Config = { provider: string model: string url?: string + cacheControl?: boolean schema?: JSONSchema7 azure?: { resourceName: string } google?: GoogleConfig diff --git a/packages/core/src/services/ai/index.test.ts b/packages/core/src/services/ai/index.test.ts index d73f9a2a1..75a0babef 100644 --- a/packages/core/src/services/ai/index.test.ts +++ b/packages/core/src/services/ai/index.test.ts @@ -39,7 +39,10 @@ describe('ai function', () => { } const messages: Message[] = [ - { role: MessageRole.system, content: 'System message' }, + { + role: MessageRole.system, + content: [{ type: ContentType.text, text: 'System message' }], + }, ] await expect( @@ -151,7 +154,10 @@ describe('ai function', () => { } const messages: Message[] = [ - { role: MessageRole.system, content: 'System message' }, + { + role: MessageRole.system, + content: [{ type: ContentType.text, text: 'System message' }], + }, { role: MessageRole.user, content: [{ type: ContentType.text, text: 'Hello' }], @@ -226,7 +232,10 @@ describe('ai function', () => { } const messages: Message[] = [ - { role: MessageRole.system, content: 'System message' }, + { + role: MessageRole.system, + content: [{ type: ContentType.text, text: 'System message' }], + }, { role: MessageRole.user, content: [{ type: ContentType.text, text: 'Hello' }], diff --git a/packages/core/src/services/ai/index.ts b/packages/core/src/services/ai/index.ts index 4334cd491..3ef666a6e 100644 --- a/packages/core/src/services/ai/index.ts +++ b/packages/core/src/services/ai/index.ts @@ -61,7 +61,7 @@ export async function ai({ provider: apiProvider, prompt, messages: originalMessages, - config, + config: originalConfig, schema, output, customLanguageModel, @@ -93,13 +93,15 @@ export async function ai({ const rule = applyCustomRules({ providerType: apiProvider.provider, messages: originalMessages, + config: originalConfig, }) const { provider, token: apiKey, url } = apiProvider + const config = rule.config as PartialConfig + const messages = rule.messages const model = config.model - - const messages = rule?.messages ?? originalMessages - const languageModelResult = createProvider({ + const tools = config.tools + const llmProvider = createProvider({ messages, provider, apiKey, @@ -107,12 +109,12 @@ export async function ai({ ...(url ? { url } : {}), }) - if (languageModelResult.error) return languageModelResult + if (llmProvider.error) return llmProvider const languageModel = customLanguageModel ? customLanguageModel - : languageModelResult.value(model) - const toolsResult = buildTools(config.tools) + : llmProvider.value(model, { cacheControl: config.cacheControl }) + const toolsResult = buildTools(tools) if (toolsResult.error) return toolsResult const commonOptions = { diff --git a/packages/core/src/services/ai/providers/rules/anthropic.test.ts b/packages/core/src/services/ai/providers/rules/anthropic.test.ts index 01e690be8..acc2368b9 100644 --- a/packages/core/src/services/ai/providers/rules/anthropic.test.ts +++ b/packages/core/src/services/ai/providers/rules/anthropic.test.ts @@ -1,69 +1,122 @@ -import { Message, TextContent } from '@latitude-data/compiler' -import { describe, expect, it } from 'vitest' +import { Message, MessageRole } from '@latitude-data/compiler' +import { beforeAll, describe, expect, it } from 'vitest' -import { applyCustomRules } from '.' +import { PartialConfig } from '../../helpers' import { Providers } from '../models' +import { applyCustomRules, ProviderRules } from './index' const providerType = Providers.Anthropic -describe('applyAntrhopicRules', () => { - it('does not modify the conversation when there are no system messages', () => { - const messages = [ - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - { - role: 'assistant', - content: 'Hello! How are you?', - }, - { - role: 'user', - content: [{ type: 'text', text: 'I am good' }], - }, - ] as Message[] +let config = {} as PartialConfig +let messages: Message[] +describe('applyAnthropicRules', () => { + describe('with system messages not at the beggining', () => { + beforeAll(() => { + messages = [ + { + role: 'system', + content: 'You are a helpful chatbot', + }, + { + role: 'system', + content: 'Respond to the user', + }, + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + { + role: 'system', + content: 'Use a short response', + }, + { + role: 'assistant', + content: 'Hi! How are you doing today?', + }, + { + role: 'system', + content: [ + { + type: 'text', + text: 'Use a short response', + }, + { + type: 'text', + text: 'Second a short response', + }, + ], + }, + ] as Message[] + }) + + it('only modifies system messages that are not at the beggining', () => { + const rules = applyCustomRules({ providerType, messages, config }) + + const appliedMessages = rules.messages + + expect(appliedMessages.length).toBe(messages.length) + expect(appliedMessages[0]).toEqual(messages[0]) + expect(appliedMessages[1]).toEqual(messages[1]) + expect(appliedMessages[2]).toEqual(messages[2]) + expect(appliedMessages[4]).toEqual({ + role: MessageRole.assistant, + content: [{ type: 'text', text: messages[4]!.content }], + }) + + expect(appliedMessages[3]).toEqual({ + role: MessageRole.user, + content: [{ type: 'text', text: messages[3]!.content }], + }) + + expect(appliedMessages[3]).toEqual({ + role: MessageRole.user, + content: [{ type: 'text', text: messages[3]!.content }], + }) - const rules = applyCustomRules({ providerType, messages }) + expect(appliedMessages[5]).toEqual({ + role: MessageRole.user, + content: [ + { type: 'text', text: 'Use a short response' }, + { type: 'text', text: 'Second a short response' }, + ], + }) + }) - expect(rules).toBeUndefined() + it('generates warning when system messages are not at the beggining', () => { + const rules = applyCustomRules({ providerType, messages, config }) + + expect(rules.rules).toEqual([ + { + rule: ProviderRules.Anthropic, + ruleMessage: + 'Anthropic only supports system messages at the beggining of the conversation. All other system messages have been converted to user messages.', + }, + ]) + }) }) - it('converts any system message to user messages', () => { - const messages = [ - { - role: 'system', - content: 'You are a helpful chatbot', - }, + it('Warns when only system messages are present', () => { + const theMessages = [ { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], + role: MessageRole.system, + content: [{ type: 'text', text: 'You are a helpful chatbot' }], }, { - role: 'system', - content: 'Respond to the user', + role: MessageRole.system, + content: [{ type: 'text', text: 'Respond to the user' }], }, + ] as Message[] + const rules = applyCustomRules({ + providerType, + messages: theMessages, + config, + }) + expect(rules.rules).toEqual([ { - role: 'assistant', - content: 'Hi! How are you doing today?', + rule: ProviderRules.Anthropic, + ruleMessage: + 'Only system messages are present. You at least need one your message or your message in Anthropic.', }, - ] as Message[] - - const rules = applyCustomRules({ providerType, messages }) - - expect(rules?.messages.length).toBe(messages.length) - - expect(rules?.messages[0]!.role).toBe('user') - expect((rules?.messages[0]!.content[0] as TextContent)?.text).toEqual( - messages[0]!.content, - ) - - expect(rules?.messages[1]).toEqual(messages[1]) - - expect(rules?.messages[2]!.role).toBe('user') - expect((rules?.messages[2]!.content[0] as TextContent)?.text).toEqual( - messages[2]!.content, - ) - - expect(rules?.messages[3]).toEqual(messages[3]) + ]) }) }) diff --git a/packages/core/src/services/ai/providers/rules/anthropic.ts b/packages/core/src/services/ai/providers/rules/anthropic.ts index 934fdae34..e8288ee40 100644 --- a/packages/core/src/services/ai/providers/rules/anthropic.ts +++ b/packages/core/src/services/ai/providers/rules/anthropic.ts @@ -1,23 +1,29 @@ -import type { ContentType, MessageRole } from '@latitude-data/compiler' +import { MessageRole } from '@latitude-data/compiler' -import { AppliedRules, ApplyCustomRulesProps } from '.' +import { AppliedRules, ProviderRules } from '.' +import { enforceAllSystemMessagesFirst } from './helpers/enforceAllSystemMessagesFirst' -export function applyAnthropicRules({ - messages, -}: ApplyCustomRulesProps): AppliedRules | undefined { - if (!messages.some((m) => m.role === 'system')) return +export function applyAnthropicRules(appliedRule: AppliedRules): AppliedRules { + const rule = enforceAllSystemMessagesFirst(appliedRule, { + provider: ProviderRules.Anthropic, + message: + 'Anthropic only supports system messages at the beggining of the conversation. All other system messages have been converted to user messages.', + }) + const roles = rule.messages.map((m) => m.role) + const onlySystemMessages = roles.every((r) => r === MessageRole.system) + if (!onlySystemMessages) return rule + + const rules = [ + ...rule.rules, + { + rule: ProviderRules.Anthropic, + ruleMessage: + 'Only system messages are present. You at least need one your message or your message in Anthropic.', + }, + ] return { - rule: 'AnthropicMultipleSystemMessagesUnsupported', - ruleMessage: - 'Anthropic does not support multiple system messages. All system messages have been converted to user messages. If you want to add a system prompt please include it in the prompt frontmatter.', - messages: messages.map((m) => { - if (m.role !== 'system') return m - return { - ...m, - role: 'user' as MessageRole.user, - content: [{ type: 'text' as ContentType.text, text: m.content }], - } - }), + ...rule, + rules, } } diff --git a/packages/core/src/services/ai/providers/rules/google.test.ts b/packages/core/src/services/ai/providers/rules/google.test.ts index edfe89495..043827cd6 100644 --- a/packages/core/src/services/ai/providers/rules/google.test.ts +++ b/packages/core/src/services/ai/providers/rules/google.test.ts @@ -1,93 +1,97 @@ -import { Message, TextContent } from '@latitude-data/compiler' -import { describe, expect, it } from 'vitest' +import { Message, MessageRole } from '@latitude-data/compiler' +import { beforeAll, describe, expect, it } from 'vitest' -import { applyCustomRules } from '.' +import { applyCustomRules, ProviderRules } from '.' +import { PartialConfig } from '../../helpers' import { Providers } from '../models' const providerType = Providers.Google +let config = {} as PartialConfig +let messages: Message[] describe('applyGoogleRules', () => { - it('does not modify the conversation when there are no system messages', () => { - const messages = [ - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - { - role: 'assistant', - content: 'Hello! How are you?', - }, - { - role: 'user', - content: [{ type: 'text', text: 'I am good' }], - }, - ] as Message[] + describe('with system messages not at the beggining', () => { + beforeAll(() => { + messages = [ + { + role: 'system', + content: 'You are a helpful chatbot', + }, + { + role: 'system', + content: 'Respond to the user', + }, + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + { + role: 'system', + content: 'Use a short response', + }, + { + role: 'assistant', + content: 'Hi! How are you doing today?', + }, + { + role: 'system', + content: [ + { + type: 'text', + text: 'Use a short response', + }, + { + type: 'text', + text: 'Second a short response', + }, + ], + }, + ] as Message[] + }) - const rules = applyCustomRules({ providerType, messages }) + it('only modifies system messages that are not at the beggining', () => { + const rules = applyCustomRules({ providerType, messages, config }) - expect(rules).toBeUndefined() - }) - - it('does not modify the conversation when all system messages are at the beggining', () => { - const messages = [ - { - role: 'system', - content: 'You are a helpful chatbot', - }, - { - role: 'system', - content: 'Respond to the user', - }, - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - { - role: 'assistant', - content: 'Hi! How are you doing today?', - }, - ] as Message[] + const appliedMessages = rules.messages - const rules = applyCustomRules({ providerType, messages }) - expect(rules).toBeUndefined() - }) + expect(appliedMessages.length).toBe(messages.length) + expect(appliedMessages[0]).toEqual(messages[0]) + expect(appliedMessages[1]).toEqual(messages[1]) + expect(appliedMessages[2]).toEqual(messages[2]) + expect(appliedMessages[4]).toEqual({ + role: MessageRole.assistant, + content: [{ type: 'text', text: messages[4]!.content }], + }) - it('only modifies system messages that are not at the beggining', () => { - const messages = [ - { - role: 'system', - content: 'You are a helpful chatbot', - }, - { - role: 'system', - content: 'Respond to the user', - }, - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - { - role: 'system', - content: 'Use a short response', - }, - { - role: 'assistant', - content: 'Hi! How are you doing today?', - }, - ] as Message[] + expect(appliedMessages[3]).toEqual({ + role: MessageRole.user, + content: [{ type: 'text', text: messages[3]!.content }], + }) - const rules = applyCustomRules({ providerType, messages }) + expect(appliedMessages[3]).toEqual({ + role: MessageRole.user, + content: [{ type: 'text', text: messages[3]!.content }], + }) - expect(rules?.messages.length).toBe(messages.length) + expect(appliedMessages[5]).toEqual({ + role: MessageRole.user, + content: [ + { type: 'text', text: 'Use a short response' }, + { type: 'text', text: 'Second a short response' }, + ], + }) + }) - expect(rules?.messages[0]).toEqual(messages[0]) - expect(rules?.messages[1]).toEqual(messages[1]) - expect(rules?.messages[2]).toEqual(messages[2]) - expect(rules?.messages[4]).toEqual(messages[4]) + it('generates warning when system messages are not at the beggining', () => { + const rules = applyCustomRules({ providerType, messages, config }) - expect(rules?.messages[3]!.role).toBe('user') - expect((rules?.messages[3]!.content[0] as TextContent)?.text).toEqual( - messages[3]!.content, - ) + expect(rules.rules).toEqual([ + { + rule: ProviderRules.Google, + ruleMessage: + 'Google only supports system messages at the beggining of the conversation. All other system messages have been converted to user messages.', + }, + ]) + }) }) }) diff --git a/packages/core/src/services/ai/providers/rules/google.ts b/packages/core/src/services/ai/providers/rules/google.ts index 6c682b46b..e13bd2694 100644 --- a/packages/core/src/services/ai/providers/rules/google.ts +++ b/packages/core/src/services/ai/providers/rules/google.ts @@ -1,33 +1,10 @@ -import type { ContentType, MessageRole } from '@latitude-data/compiler' +import { enforceAllSystemMessagesFirst } from './helpers/enforceAllSystemMessagesFirst' +import { AppliedRules, ProviderRules } from './index' -import { AppliedRules, ApplyCustomRulesProps } from '.' - -export function applyGoogleRules({ - messages, -}: ApplyCustomRulesProps): AppliedRules | undefined { - const firstNonSystemMessageIndex = messages.findIndex( - (m) => m.role !== 'system', - ) - if (firstNonSystemMessageIndex === -1) return - - const messagesAfterFirstNonSystemMessage = messages.slice( - firstNonSystemMessageIndex, - ) - if (!messagesAfterFirstNonSystemMessage.some((m) => m.role === 'system')) - return - - return { - rule: 'GoogleSingleStartingSystemMessageSupported', - ruleMessage: +export function applyGoogleRules(appliedRule: AppliedRules): AppliedRules { + return enforceAllSystemMessagesFirst(appliedRule, { + provider: ProviderRules.Google, + message: 'Google only supports system messages at the beggining of the conversation. All other system messages have been converted to user messages.', - messages: messages.map((m, i) => { - if (i < firstNonSystemMessageIndex) return m - if (m.role !== 'system') return m - return { - ...m, - role: 'user' as MessageRole.user, - content: [{ type: 'text' as ContentType.text, text: m.content }], - } - }), - } + }) } diff --git a/packages/core/src/services/ai/providers/rules/helpers/enforceAllSystemMessagesFirst.ts b/packages/core/src/services/ai/providers/rules/helpers/enforceAllSystemMessagesFirst.ts new file mode 100644 index 000000000..7db21d10d --- /dev/null +++ b/packages/core/src/services/ai/providers/rules/helpers/enforceAllSystemMessagesFirst.ts @@ -0,0 +1,53 @@ +import { ContentType, MessageRole } from '@latitude-data/compiler' + +import { AppliedRules, ProviderRules } from '../index' + +const system = MessageRole.system + +export function enforceAllSystemMessagesFirst( + appliedRule: AppliedRules, + ruleConfig: { + provider: ProviderRules + message: string + }, +): AppliedRules { + const messages = appliedRule.messages + const firstNonSystemMessageIndex = messages.findIndex( + (m) => m.role !== system, + ) + + if (firstNonSystemMessageIndex === -1) { + return appliedRule + } + + const messagesAfterFirstNonSystemMessage = messages.slice( + firstNonSystemMessageIndex, + ) + if (!messagesAfterFirstNonSystemMessage.some((m) => m.role === system)) { + return appliedRule + } + + const rules = [ + ...appliedRule.rules, + { + rule: ruleConfig.provider, + ruleMessage: ruleConfig.message, + }, + ] + return { + ...appliedRule, + rules, + messages: messages.map((m, i) => { + if (i < firstNonSystemMessageIndex) return m + if (m.role !== system) return m + + return { + ...m, + role: MessageRole.user, + content: Array.isArray(m.content) + ? m.content + : [{ type: ContentType.text, text: m.content }], + } + }), + } +} diff --git a/packages/core/src/services/ai/providers/rules/index.ts b/packages/core/src/services/ai/providers/rules/index.ts index 0e62da292..bd5050966 100644 --- a/packages/core/src/services/ai/providers/rules/index.ts +++ b/packages/core/src/services/ai/providers/rules/index.ts @@ -1,34 +1,48 @@ -import type { Message } from '@latitude-data/compiler' +import type { Config, Message } from '@latitude-data/compiler' +import { PartialConfig } from '../../helpers' import { Providers } from '../models' import { applyAnthropicRules } from './anthropic' import { applyGoogleRules } from './google' +import { vercelSdkRules } from './vercel' -export type ProviderRules = - | 'AnthropicMultipleSystemMessagesUnsupported' - | 'GoogleSingleStartingSystemMessageSupported' +export enum ProviderRules { + Anthropic = 'anthropic', + Google = 'google', + VercelSDK = 'latitude', +} + +type ProviderRule = { rule: ProviderRules; ruleMessage: string } export type AppliedRules = { - rule: ProviderRules - ruleMessage?: string + rules: ProviderRule[] messages: Message[] + config: Config } -export type ApplyCustomRulesProps = { +type Props = { + providerType: Providers messages: Message[] + config: Config | PartialConfig } export function applyCustomRules({ providerType, messages, -}: ApplyCustomRulesProps & { providerType: Providers }): - | AppliedRules - | undefined { + config, +}: Props): AppliedRules { + let rules: AppliedRules = { + rules: [], + messages, + config, + } if (providerType === Providers.Anthropic) { - return applyAnthropicRules({ messages }) + rules = applyAnthropicRules(rules) } if (providerType === Providers.Google) { - return applyGoogleRules({ messages }) + rules = applyGoogleRules(rules) } + + return vercelSdkRules(rules, providerType) } diff --git a/packages/core/src/services/ai/providers/rules/providerMetadata/index.ts b/packages/core/src/services/ai/providers/rules/providerMetadata/index.ts new file mode 100644 index 000000000..20b2e8901 --- /dev/null +++ b/packages/core/src/services/ai/providers/rules/providerMetadata/index.ts @@ -0,0 +1,151 @@ +import { Message, MessageRole } from '@latitude-data/compiler' + +import { Providers } from '../../models' + +export const PROVIDER_TO_METADATA_KEY: Record = { + [Providers.OpenAI]: 'openai', + [Providers.Anthropic]: 'anthropic', + [Providers.Groq]: 'groq', + [Providers.Mistral]: 'mistral', + [Providers.Azure]: 'azure', + [Providers.Google]: 'google', + [Providers.Custom]: 'custom', +} + +const CONTENT_DEFINED_ATTRIBUTES = [ + 'text', + 'type', + 'image', + 'mimeType', + 'data', + 'toolCallId', + 'toolName', + 'args', +] as const + +type AttrArgs = { attributes: string[]; content: Record } +function genericAttributesProcessor({ attributes, content }: AttrArgs) { + return attributes.reduce((acc, key) => ({ ...acc, [key]: content[key] }), {}) +} + +function anthropicAttributesProcessor({ attributes, content }: AttrArgs) { + return attributes.reduce((acc, key) => { + const safeKey = key === 'cache_control' ? 'cacheControl' : key + return { ...acc, [safeKey]: content[key] } + }, {}) +} + +function processAttributes({ + attributes, + provider, + content, +}: AttrArgs & { + provider: Providers +}) { + switch (provider) { + case Providers.Anthropic: + return anthropicAttributesProcessor({ attributes, content }) + default: + return genericAttributesProcessor({ attributes, content }) + } +} + +export function getProviderMetadataKey(provider: Providers) { + return PROVIDER_TO_METADATA_KEY[provider] +} + +export type ProviderMetadata = Record> + +export function extractContentMetadata({ + content, + provider, +}: { + content: Record + provider: Providers +}) { + const definedAttributes = Object.keys(content).filter((key) => + CONTENT_DEFINED_ATTRIBUTES.includes( + key as (typeof CONTENT_DEFINED_ATTRIBUTES)[number], + ), + ) as (typeof CONTENT_DEFINED_ATTRIBUTES)[number][] + + const providerAttributes = Object.keys(content).filter( + (key) => !CONTENT_DEFINED_ATTRIBUTES.includes(key as any), + ) + const definedData = definedAttributes.reduce( + // @ts-ignore + (acc, key) => ({ ...acc, [key]: content[key] }), + {} as Record<(typeof CONTENT_DEFINED_ATTRIBUTES)[number], unknown>, + ) + + if (!providerAttributes.length) { + return definedData + } + + return { + ...definedData, + experimental_providerMetadata: { + [getProviderMetadataKey(provider)]: processAttributes({ + attributes: providerAttributes, + provider, + content, + }), + }, + } +} + +function removeUndefinedValues(data: Record) { + return Object.keys(data).reduce((acc, item) => { + if (data[item] === undefined) return acc + return { ...acc, [item]: data[item] } + }, {}) +} + +type MessageWithMetadata = Message & { + experimental_providerMetadata?: Record> +} +export function extractMessageMetadata({ + message, + provider, +}: { + message: Message + provider: Providers +}): MessageWithMetadata { + const { role, content, toolCalls, ...rest } = message + + let common = removeUndefinedValues({ + role, + content, + toolCalls, + }) as Message & { name?: string } + + if (Object.keys(rest).length === 0) return common + + if (role === MessageRole.user && Object.hasOwnProperty.call(rest, 'name')) { + // @ts-ignore + const name = rest.name + common = { + ...common, + // Issue: https://github.com/vercel/ai/pull/2199 + name, + } + } + + const { name: _, ...restWithoutName } = rest as { + name?: string + [key: string]: unknown + } + + if (!Object.keys(restWithoutName).length) return common + + return { + ...common, + experimental_providerMetadata: { + [getProviderMetadataKey(provider)]: processAttributes({ + attributes: Object.keys(restWithoutName), + provider, + content: restWithoutName, + }), + }, + } +} diff --git a/packages/core/src/services/ai/providers/rules/vercel.test.ts b/packages/core/src/services/ai/providers/rules/vercel.test.ts new file mode 100644 index 000000000..6ba0cbd0b --- /dev/null +++ b/packages/core/src/services/ai/providers/rules/vercel.test.ts @@ -0,0 +1,338 @@ +import { Message } from '@latitude-data/compiler' +import { describe, expect, it } from 'vitest' + +import { PartialConfig } from '../../helpers' +import { Providers } from '../models' +import { vercelSdkRules } from './vercel' + +let messages: Message[] +let config = {} as PartialConfig + +describe('applyVercelSdkRules', () => { + it('modify plain text messages to object', () => { + messages = [ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + { + role: 'assistant', + content: 'Hello! How are you?', + }, + { + role: 'user', + content: [{ type: 'text', text: 'I am good' }], + }, + ] as Message[] + + const rules = vercelSdkRules( + { rules: [], messages, config }, + Providers.Anthropic, + ) + + expect(rules.messages).toEqual([ + ...(messages.slice(0, 1) as Message[]), + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello! How are you?' }], + }, + { + role: 'user', + content: [{ type: 'text', text: 'I am good' }], + }, + ]) + }) + + it('put each system message part in a system message and set custom attributes there', () => { + messages = [ + { + role: 'system', + content: [ + { type: 'text', text: 'I am a' }, + { type: 'text', text: 'system message' }, + ], + }, + ] as Message[] + + const rules = vercelSdkRules( + { rules: [], messages, config }, + Providers.Anthropic, + ) + expect(rules.messages).toEqual([ + { role: 'system', content: 'I am a' }, + { role: 'system', content: 'system message' }, + ]) + }) + + it('already flattened messages', () => { + messages = [ + { role: 'system', content: 'I am a' }, + { role: 'system', content: 'system message' }, + ] as unknown as Message[] + + const rules = vercelSdkRules( + { rules: [], messages, config }, + Providers.Anthropic, + ) + expect(rules.messages).toEqual([ + { role: 'system', content: 'I am a' }, + { role: 'system', content: 'system message' }, + ]) + }) + + it('put root system message metadata into text parts', () => { + messages = [ + { + role: 'system', + cache_control: { type: 'ephemeral' }, + content: [ + { type: 'text', text: 'I am a' }, + { type: 'text', text: 'system message' }, + ], + }, + ] as Message[] + + const rules = vercelSdkRules( + { rules: [], messages, config }, + Providers.Anthropic, + ) + expect(rules.messages).toEqual([ + { + role: 'system', + content: 'I am a', + experimental_providerMetadata: { + anthropic: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + { + role: 'system', + content: 'system message', + experimental_providerMetadata: { + anthropic: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + ]) + }) + + it('put root user message metadata into text parts', () => { + messages = [ + { + role: 'user', + cache_control: { type: 'ephemeral' }, + content: [ + { type: 'text', text: 'I am a' }, + { type: 'text', text: 'system message' }, + ], + }, + ] as Message[] + + const rules = vercelSdkRules( + { rules: [], messages, config }, + Providers.Anthropic, + ) + expect(rules.messages).toEqual([ + { + role: 'user', + experimental_providerMetadata: { + anthropic: { + cacheControl: { type: 'ephemeral' }, + }, + }, + content: [ + { + type: 'text', + text: 'I am a', + experimental_providerMetadata: { + anthropic: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + { + type: 'text', + text: 'system message', + experimental_providerMetadata: { + anthropic: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + ], + }, + ]) + }) + + it('transform message attributes into provider metadata', () => { + messages = [ + { + role: 'user', + name: 'paco', + content: [{ type: 'text', text: 'Hello' }], + some_attribute: 'some_user_value', + another_attribute: { another_user: 'value' }, + }, + { + role: 'assistant', + content: 'Hello! How are you?', + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'I am good' }], + some_attribute: 'some_assistant_value', + another_attribute: { another_assistant: 'value' }, + }, + { + role: 'system', + content: [{ type: 'text', text: 'I am good' }], + some_attribute: 'some_system_value', + cache_control: { type: 'ephemeral' }, + another_attribute: { another_system: 'value' }, + }, + ] as Message[] + + const rules = vercelSdkRules( + { rules: [], messages, config }, + Providers.Anthropic, + ) + const transformedMessages = rules.messages + expect(transformedMessages).toEqual([ + { + role: 'user', + name: 'paco', + content: [ + { + type: 'text', + text: 'Hello', + experimental_providerMetadata: { + anthropic: { + some_attribute: 'some_user_value', + another_attribute: { another_user: 'value' }, + }, + }, + }, + ], + experimental_providerMetadata: { + anthropic: { + some_attribute: 'some_user_value', + another_attribute: { another_user: 'value' }, + }, + }, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello! How are you?' }], + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'I am good', + experimental_providerMetadata: { + anthropic: { + some_attribute: 'some_assistant_value', + another_attribute: { another_assistant: 'value' }, + }, + }, + }, + ], + experimental_providerMetadata: { + anthropic: { + some_attribute: 'some_assistant_value', + another_attribute: { another_assistant: 'value' }, + }, + }, + }, + { + role: 'system', + content: 'I am good', + experimental_providerMetadata: { + anthropic: { + some_attribute: 'some_system_value', + cacheControl: { type: 'ephemeral' }, + another_attribute: { another_system: 'value' }, + }, + }, + }, + ]) + }) + + it('transform message content attributes into content provider metadata', () => { + messages = [ + { + role: 'user', + name: 'paco', + content: [ + { + type: 'text', + text: 'Hello', + some_attribute: 'some_user_value', + another_attribute: { another_user: 'value' }, + }, + ], + }, + { + role: 'assistant', + content: 'Hello! How are you?', + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'I am good', + some_attribute: 'some_assistant_value', + another_attribute: { another_assistant: 'value' }, + }, + ], + }, + ] as Message[] + + const rules = vercelSdkRules( + { rules: [], messages, config }, + Providers.Anthropic, + ) + const transformedMessages = rules.messages + expect(transformedMessages).toEqual([ + { + role: 'user', + name: 'paco', + content: [ + { + type: 'text', + text: 'Hello', + experimental_providerMetadata: { + anthropic: { + some_attribute: 'some_user_value', + another_attribute: { another_user: 'value' }, + }, + }, + }, + ], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Hello! How are you?' }], + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'I am good', + experimental_providerMetadata: { + anthropic: { + some_attribute: 'some_assistant_value', + another_attribute: { another_assistant: 'value' }, + }, + }, + }, + ], + }, + ]) + }) +}) diff --git a/packages/core/src/services/ai/providers/rules/vercel.ts b/packages/core/src/services/ai/providers/rules/vercel.ts new file mode 100644 index 000000000..a769fbf6f --- /dev/null +++ b/packages/core/src/services/ai/providers/rules/vercel.ts @@ -0,0 +1,129 @@ +import { + ContentType, + Message, + MessageRole, + SystemMessage, + TextContent, +} from '@latitude-data/compiler' + +import { Providers } from '../models' +import { AppliedRules } from './index' +import { + extractContentMetadata, + extractMessageMetadata, + getProviderMetadataKey, + type ProviderMetadata, +} from './providerMetadata' + +function flattenSystemMessage({ + message, + provider, +}: { + message: SystemMessage + provider: Providers +}): Message[] { + const content = message.content as TextContent[] | string + + // NOTO: `applyCustomRules` can be invoked multiple times + // during a chain. if system message.content is already + // a string we consider it already processed. + if (typeof content === 'string') return [message] + + const msgMetadata = + extractMessageMetadata({ + message, + provider, + }).experimental_providerMetadata ?? {} + + return content.flatMap((content) => { + const extracted = extractContentMetadata({ content, provider }) + // @ts-expect-error - metadata key can be not present + const metadata = (extracted.experimental_providerMetadata ?? + {}) as ProviderMetadata + const baseMsg = { role: message.role, content: content.text } + + if (!Object.keys(metadata).length && !Object.keys(msgMetadata).length) { + return baseMsg + } + + const key = getProviderMetadataKey(provider) + + return { + ...baseMsg, + experimental_providerMetadata: { + [key]: { + ...(msgMetadata?.[key] || {}), + ...(metadata?.[key] || {}), + }, + }, + } + }) as unknown as Message[] +} + +function groupContentMetadata({ + content, + provider, + messageMetadata, +}: { + content: Message['content'] + provider: Providers + messageMetadata?: ProviderMetadata +}) { + const key = getProviderMetadataKey(provider) + + if (typeof content === 'string') { + const baseMsg = { type: ContentType.text, text: content } + if (!messageMetadata) return [baseMsg] + + return [ + { + ...baseMsg, + experimental_providerMetadata: messageMetadata, + }, + ] + } + + return content.map((contentItem) => { + const extracted = extractContentMetadata({ content: contentItem, provider }) + if (!messageMetadata) return extracted + + // @ts-expect-error - metadata key can be not present + const contentMetadata = (extracted.experimental_providerMetadata ?? + {}) as ProviderMetadata + + return { + ...extracted, + experimental_providerMetadata: { + [key]: { + ...(messageMetadata?.[key] || {}), + ...(contentMetadata?.[key] || {}), + }, + }, + } + }) +} + +export function vercelSdkRules( + rules: AppliedRules, + provider: Providers, +): AppliedRules { + const messages = rules.messages.flatMap((message) => { + if (message.role === MessageRole.system) { + return flattenSystemMessage({ message, provider }) + } + + const msg = extractMessageMetadata({ + message, + provider, + }) + const content = groupContentMetadata({ + content: msg.content, + provider, + messageMetadata: msg.experimental_providerMetadata, + }) as unknown as Message['content'] + + return [{ ...msg, content } as Message] + }) as Message[] + + return { ...rules, messages } +} diff --git a/packages/core/src/services/chains/ChainValidator/index.ts b/packages/core/src/services/chains/ChainValidator/index.ts index fe537d2e9..86011a222 100644 --- a/packages/core/src/services/chains/ChainValidator/index.ts +++ b/packages/core/src/services/chains/ChainValidator/index.ts @@ -73,11 +73,12 @@ export class ChainValidator { const rule = applyCustomRules({ providerType: provider.provider, messages: conversation.messages, + config, }) return Result.ok({ provider, - config, + config: rule.config as Config, chainCompleted, conversation: { ...conversation, diff --git a/packages/core/src/services/chains/run.test.ts b/packages/core/src/services/chains/run.test.ts index 2752d7d65..2bb5c73c9 100644 --- a/packages/core/src/services/chains/run.test.ts +++ b/packages/core/src/services/chains/run.test.ts @@ -257,7 +257,7 @@ describe('runChain', () => { messages: [ { role: MessageRole.system, - content: 'System instruction', + content: [{ type: ContentType.text, text: 'System instruction' }], }, { role: MessageRole.user, diff --git a/packages/core/src/services/commits/runDocumentAtCommit.test.ts b/packages/core/src/services/commits/runDocumentAtCommit.test.ts index 156e40f1c..8ad6d3cf0 100644 --- a/packages/core/src/services/commits/runDocumentAtCommit.test.ts +++ b/packages/core/src/services/commits/runDocumentAtCommit.test.ts @@ -170,7 +170,7 @@ This is a test document { role: 'system', content: 'This is a test document' }, { role: 'assistant', - content: 'Fake AI generated text', + content: [{ type: 'text', text: 'Fake AI generated text' }], toolCalls: [], }, ], @@ -243,7 +243,7 @@ This is a test document messages: [ { role: 'assistant', - content: 'Fake AI generated text', + content: [{ type: 'text', text: 'Fake AI generated text' }], toolCalls: [], }, ], diff --git a/packages/core/src/services/providerLogs/serialize.test.ts b/packages/core/src/services/providerLogs/serialize.test.ts index 3a4aa078f..3d71e4911 100644 --- a/packages/core/src/services/providerLogs/serialize.test.ts +++ b/packages/core/src/services/providerLogs/serialize.test.ts @@ -91,7 +91,12 @@ describe('serialize', () => { // @ts-expect-error const providerLog: ProviderLog = { messages: [ - { role: MessageRole.system, content: 'You are an AI assistant' }, + { + role: MessageRole.system, + content: [ + { type: ContentType.text, text: 'You are an AI assistant' }, + ], + }, { role: MessageRole.user, content: [ @@ -109,7 +114,9 @@ describe('serialize', () => { all: [ { role: MessageRole.system, - content: 'You are an AI assistant', + content: [ + { type: ContentType.text, text: 'You are an AI assistant' }, + ], }, { role: MessageRole.user, @@ -125,7 +132,7 @@ describe('serialize', () => { ], first: { role: MessageRole.system, - content: 'You are an AI assistant', + content: [{ type: ContentType.text, text: 'You are an AI assistant' }], }, last: { role: MessageRole.assistant, @@ -158,16 +165,22 @@ describe('serialize', () => { all: [ { role: MessageRole.system, - content: 'You are an AI assistant', + content: [ + { type: ContentType.text, text: 'You are an AI assistant' }, + ], }, ], first: { role: MessageRole.system, - content: 'You are an AI assistant', + content: [ + { type: ContentType.text, text: 'You are an AI assistant' }, + ], }, last: { role: MessageRole.system, - content: 'You are an AI assistant', + content: [ + { type: ContentType.text, text: 'You are an AI assistant' }, + ], }, }, assistant: { @@ -319,7 +332,12 @@ describe('formatContext', () => { // @ts-expect-error const providerLog: ProviderLog = { messages: [ - { role: MessageRole.system, content: 'You are an AI assistant' }, + { + role: MessageRole.system, + content: [ + { type: ContentType.text, text: 'You are an AI assistant' }, + ], + }, { role: MessageRole.user, content: [ diff --git a/packages/core/src/services/providerLogs/serialize.ts b/packages/core/src/services/providerLogs/serialize.ts index 10673585f..89b5a6162 100644 --- a/packages/core/src/services/providerLogs/serialize.ts +++ b/packages/core/src/services/providerLogs/serialize.ts @@ -1,4 +1,4 @@ -import { MessageRole } from '@latitude-data/compiler' +import { ContentType, MessageRole } from '@latitude-data/compiler' import { Message, @@ -52,7 +52,8 @@ export function formatContext( Array.isArray(message.content) && message.content && message.content[0] && - 'text' in message.content[0] + 'text' in message.content[0] && + message.content[0].type === ContentType.text ) { content = message.content[0].text } else if ( diff --git a/packages/web-ui/src/ds/molecules/Chat/Message/index.tsx b/packages/web-ui/src/ds/molecules/Chat/Message/index.tsx index 3694ff89d..fbc76a45d 100644 --- a/packages/web-ui/src/ds/molecules/Chat/Message/index.tsx +++ b/packages/web-ui/src/ds/molecules/Chat/Message/index.tsx @@ -81,7 +81,7 @@ const ContentValue = ({ wordBreak='breakAll' key={`${index}-${lineIndex}`} > - {line} + {line ? line : '\n'} )) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91ee34931..4b4dfe4ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,7 +279,7 @@ importers: specifier: ^8.2.4 version: 8.3.5(jiti@1.21.6)(postcss@8.4.47)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) tsx: - specifier: ^4.16.2 + specifier: ^4.19.2 version: 4.19.2 vitest: specifier: ^2.0.4