diff --git a/config/cortex.php b/config/cortex.php index c5d31b8..ec662ef 100644 --- a/config/cortex.php +++ b/config/cortex.php @@ -193,6 +193,36 @@ ], ], + /* + |-------------------------------------------------------------------------- + | Prompt Factories + |-------------------------------------------------------------------------- + | + | Here you may define the prompt factories. + | + | Supported drivers: "langfuse", "mcp" + | + */ + 'prompt_factory' => [ + 'default' => env('CORTEX_DEFAULT_PROMPT_FACTORY', 'langfuse'), + + 'langfuse' => [ + 'username' => env('LANGFUSE_USERNAME', ''), + 'password' => env('LANGFUSE_PASSWORD', ''), + 'base_uri' => env('LANGFUSE_BASE_URI', 'https://cloud.langfuse.com'), + + 'cache' => [ + 'enabled' => env('CORTEX_PROMPT_CACHE_ENABLED', false), + 'store' => env('CORTEX_PROMPT_CACHE_STORE', null), + 'ttl' => env('CORTEX_PROMPT_CACHE_TTL', 3600), + ], + ], + + // 'mcp' => [ + // 'base_uri' => env('MCP_BASE_URI', 'http://localhost:3000'), + // ], + ], + /* |-------------------------------------------------------------------------- | Embedding Providers diff --git a/scratchpad.php b/scratchpad.php index efb40bd..b61d040 100644 --- a/scratchpad.php +++ b/scratchpad.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Cortex\Cortex; +use Cortex\Prompts\Prompt; use Cortex\JsonSchema\SchemaFactory; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\LLM\Data\Messages\SystemMessage; @@ -18,11 +19,45 @@ // Or more simply $result = Cortex::prompt('What is the capital of {country}?') - ->llm('anthropic', 'claude-3-5-sonnet-20240620') + ->metadata( + provider: 'anthropic', + model: 'claude-3-5-sonnet-20240620', + structuredOutput: SchemaFactory::object()->properties( + SchemaFactory::string('capital'), + ), + ) + ->llm() ->invoke([ 'country' => 'France', ]); +// Get a text prompt builder +$prompt = Cortex::prompt('What is the capital of {country}?'); + +// Get a chat prompt builder +$prompt = Cortex::prompt([ + new SystemMessage('You are an expert at geography.'), + new UserMessage('What is the capital of {country}?'), +]); + +// Get a prompt from the given factory +$prompt = Cortex::prompt()->factory('langfuse')->make('test-prompt'); + +// Get a chat prompt builder +$prompt = Cortex::prompt()->builder('chat')->messages([ + new SystemMessage('You are an expert at geography.'), + new UserMessage('What is the capital of {country}?'), +]); + +// Get a text prompt builder +$prompt = Cortex::prompt()->builder('text'); + +$prompt = Prompt::factory()->make('test-prompt'); +$prompt = Prompt::builder()->messages([ + new SystemMessage('You are an expert at geography.'), + new UserMessage('What is the capital of {country}?'), +]); + // Uses default llm driver $result = Cortex::llm()->invoke([ new SystemMessage('You are a helpful assistant'), diff --git a/src/Cortex.php b/src/Cortex.php index aa20a01..e212c66 100644 --- a/src/Cortex.php +++ b/src/Cortex.php @@ -5,11 +5,12 @@ namespace Cortex; use Cortex\Facades\LLM; +use Cortex\Prompts\Prompt; use Cortex\Facades\Embeddings; use Cortex\Tasks\Enums\TaskType; use Cortex\Tasks\Builders\TextTaskBuilder; +use Cortex\Prompts\Contracts\PromptBuilder; use Cortex\LLM\Contracts\LLM as LLMContract; -use Cortex\Prompts\Builders\ChatPromptBuilder; use Cortex\LLM\Data\Messages\MessageCollection; use Cortex\Tasks\Builders\StructuredTaskBuilder; use Cortex\Embeddings\Contracts\Embeddings as EmbeddingsContract; @@ -17,20 +18,22 @@ class Cortex { /** - * Create a chat prompt builder. + * Create a prompt builder or factory. * - * @param \Cortex\LLM\Data\Messages\MessageCollection|array|string $messages + * @param \Cortex\LLM\Data\Messages\MessageCollection|array|string|null $messages + * + * @return ($messages is null ? \Cortex\Prompts\Prompt : ($messages is string ? \Cortex\Prompts\Builders\TextPromptBuilder : \Cortex\Prompts\Builders\ChatPromptBuilder)) */ public static function prompt( - MessageCollection|array|string|null $messages, - ): ChatPromptBuilder { - $builder = new ChatPromptBuilder(); - - if ($messages !== null) { - $builder->messages($messages); + MessageCollection|array|string|null $messages = null, + ): Prompt|PromptBuilder { + if (func_num_args() === 0) { + return new Prompt(); } - return $builder; + return is_string($messages) + ? Prompt::builder('text')->text($messages) + : Prompt::builder('chat')->messages($messages); } /** @@ -47,9 +50,15 @@ public static function llm(?string $provider = null, ?string $model = null): LLM return $llm; } - public static function embeddings(?string $driver = null): EmbeddingsContract + public static function embeddings(?string $driver = null, ?string $model = null): EmbeddingsContract { - return Embeddings::driver($driver); + $embeddings = Embeddings::driver($driver); + + if ($model !== null) { + $embeddings->withModel($model); + } + + return $embeddings; } /** diff --git a/src/CortexServiceProvider.php b/src/CortexServiceProvider.php index aec67f0..b0aa236 100644 --- a/src/CortexServiceProvider.php +++ b/src/CortexServiceProvider.php @@ -9,7 +9,9 @@ use Cortex\ModelInfo\ModelInfoFactory; use Spatie\LaravelPackageTools\Package; use Cortex\Embeddings\EmbeddingsManager; +use Cortex\Prompts\PromptFactoryManager; use Cortex\Embeddings\Contracts\Embeddings; +use Cortex\Prompts\Contracts\PromptFactory; use Illuminate\Contracts\Container\Container; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -30,6 +32,10 @@ public function packageRegistered(): void $this->app->alias('cortex.embeddings', EmbeddingsManager::class); $this->app->bind(Embeddings::class, fn(Container $app) => $app->make('cortex.embeddings')->driver()); + $this->app->singleton('cortex.prompt_factory', fn(Container $app): PromptFactoryManager => new PromptFactoryManager($app)); + $this->app->alias('cortex.prompt_factory', PromptFactoryManager::class); + $this->app->bind(PromptFactory::class, fn(Container $app) => $app->make('cortex.prompt_factory')->driver()); + $this->app->singleton('cortex.model_info_factory', function (Container $app): ModelInfoFactory { $providers = []; foreach ($app->make('config')->get('cortex.model_info_providers', []) as $provider => $config) { diff --git a/src/Facades/PromptFactory.php b/src/Facades/PromptFactory.php new file mode 100644 index 0000000..089d845 --- /dev/null +++ b/src/Facades/PromptFactory.php @@ -0,0 +1,22 @@ + $options = []) + * @method static string getDefaultDriver() + * + * @see \Cortex\Prompts\PromptFactoryManager + */ +class PromptFactory extends Facade +{ + protected static function getFacadeAccessor(): string + { + return 'cortex.prompt_factory'; + } +} diff --git a/src/LLM/AbstractLLM.php b/src/LLM/AbstractLLM.php index 5e1a36e..8ae74e6 100644 --- a/src/LLM/AbstractLLM.php +++ b/src/LLM/AbstractLLM.php @@ -364,6 +364,8 @@ public function shouldApplyFormatInstructions(bool $applyFormatInstructions = tr /** * Apply the given format instructions to the messages. + * + * @return \Cortex\LLM\Data\Messages\MessageCollection */ protected static function applyFormatInstructions( MessageCollection $messages, diff --git a/src/LLM/Data/ChatStreamResult.php b/src/LLM/Data/ChatStreamResult.php index 2f43e3a..f784787 100644 --- a/src/LLM/Data/ChatStreamResult.php +++ b/src/LLM/Data/ChatStreamResult.php @@ -40,13 +40,13 @@ public static function fake(?string $string = null, ?ToolCallCollection $toolCal index: $index, createdAt: new DateTimeImmutable(), finishReason: $isFinal ? FinishReason::Stop : null, - contentSoFar: $contentSoFar, - isFinal: $isFinal, usage: new Usage( promptTokens: 0, completionTokens: $index, totalTokens: $index, ), + contentSoFar: $contentSoFar, + isFinal: $isFinal, ); // $this->dispatchEvent( diff --git a/src/LLM/Drivers/AnthropicChat.php b/src/LLM/Drivers/AnthropicChat.php index 9555131..01d1edb 100644 --- a/src/LLM/Drivers/AnthropicChat.php +++ b/src/LLM/Drivers/AnthropicChat.php @@ -63,6 +63,11 @@ public function invoke( throw new LLMException('You must provide at least one message to the LLM.'); } + // Apply format instructions if applicable. + if ($this->shouldApplyFormatInstructions && $formatInstructions = $this->outputParser?->formatInstructions()) { + $messages = static::applyFormatInstructions($messages, $formatInstructions); + } + [$systemMessages, $messages] = $messages->partition( fn(Message $message): bool => $message instanceof SystemMessage, ); @@ -83,8 +88,6 @@ public function invoke( $params['system'] = $systemMessage->text(); } - // TODO: Apply format instructions if applicable. - $this->dispatchEvent(new ChatModelStart($messages, $params)); try { diff --git a/src/LLM/Drivers/OpenAIChat.php b/src/LLM/Drivers/OpenAIChat.php index f2864d6..b9025eb 100644 --- a/src/LLM/Drivers/OpenAIChat.php +++ b/src/LLM/Drivers/OpenAIChat.php @@ -443,11 +443,21 @@ protected function buildParams(array $additionalParameters): array ...$additionalParameters, ]; - if ($this->modelProvider === ModelProvider::OpenAI && isset($allParams['max_tokens'])) { - // `max_tokens` is deprecated in favour of `max_completion_tokens` - // https://platform.openai.com/docs/api-reference/chat/create#chat-create-max_tokens - $allParams['max_completion_tokens'] = $allParams['max_tokens']; - unset($allParams['max_tokens']); + if ($this->modelProvider === ModelProvider::OpenAI) { + if (array_key_exists('max_tokens', $allParams)) { + // `max_tokens` is deprecated in favour of `max_completion_tokens` + // https://platform.openai.com/docs/api-reference/chat/create#chat-create-max_tokens + $allParams['max_completion_tokens'] = $allParams['max_tokens']; + unset($allParams['max_tokens']); + } + + if (array_key_exists('temperature', $allParams) && $allParams['temperature'] === null) { + unset($allParams['temperature']); + } + + if (array_key_exists('top_p', $allParams) && $allParams['top_p'] === null) { + unset($allParams['top_p']); + } } return $allParams; diff --git a/src/LLM/LLMManager.php b/src/LLM/LLMManager.php index 2366dad..eda613f 100644 --- a/src/LLM/LLMManager.php +++ b/src/LLM/LLMManager.php @@ -62,7 +62,7 @@ protected function createDriver($driver): LLM // @pest-ignore-type /** * Create an OpenAI LLM driver instance. * - * @param array $config + * @param array{default_model?: string, model_provider?: string, default_parameters: array, options: array{api_key?: string, organization?: string, base_uri?: string, headers?: array, query_params?: array}} $config */ public function createOpenAIDriver(array $config, string $name): OpenAIChat|CacheDecorator { @@ -142,7 +142,7 @@ public function createAnthropicDriver(array $config, string $name): AnthropicCha } /** - * @param array $config + * @param array{model_provider?: string} $config * * @throws \InvalidArgumentException */ diff --git a/src/OutputParsers/AbstractOutputParser.php b/src/OutputParsers/AbstractOutputParser.php index 96a40aa..1764543 100644 --- a/src/OutputParsers/AbstractOutputParser.php +++ b/src/OutputParsers/AbstractOutputParser.php @@ -40,9 +40,11 @@ public function formatInstructions(): ?string /** * Set the format instructions to use in the prompt. */ - public function setFormatInstructions(string $formatInstructions): void + public function withFormatInstructions(string $formatInstructions): self { $this->formatInstructions = $formatInstructions; + + return $this; } public function handlePipeable(mixed $payload, Closure $next): mixed diff --git a/src/OutputParsers/EnumOutputParser.php b/src/OutputParsers/EnumOutputParser.php index 765e107..6865de0 100644 --- a/src/OutputParsers/EnumOutputParser.php +++ b/src/OutputParsers/EnumOutputParser.php @@ -12,7 +12,7 @@ class EnumOutputParser extends AbstractOutputParser { /** - * @param class-string $enum + * @param class-string<\BackedEnum> $enum */ public function __construct( protected string $enum, @@ -20,8 +20,8 @@ public function __construct( public function parse(ChatGeneration|ChatGenerationChunk|string $output): BackedEnum { - if (! enum_exists($this->enum)) { - throw OutputParserException::failed(sprintf('Enum %s does not exist', $this->enum)); + if (! enum_exists($this->enum) || ! is_subclass_of($this->enum, BackedEnum::class)) { + throw OutputParserException::failed(sprintf('Invalid enum: %s', $this->enum)); } $enumName = class_basename($this->enum); diff --git a/src/OutputParsers/StructuredOutputParser.php b/src/OutputParsers/StructuredOutputParser.php index 0e35317..81dd025 100644 --- a/src/OutputParsers/StructuredOutputParser.php +++ b/src/OutputParsers/StructuredOutputParser.php @@ -25,7 +25,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): array $parser = match (true) { is_string($output) => new JsonOutputParser(), // If the message has tool calls and no text, assume we are using the schema tool - $output->message->hasToolCalls() && in_array($output->message->text(), [null, '', '0'], true) => new JsonOutputToolsParser(singleToolCall: true), + $output->message->hasToolCalls() && in_array($output->message->text(), [null, ''], true) => new JsonOutputToolsParser(singleToolCall: true), default => new JsonOutputParser(), }; diff --git a/src/Prompts/Builders/ChatPromptBuilder.php b/src/Prompts/Builders/ChatPromptBuilder.php index 9c16d27..8cbfcd5 100644 --- a/src/Prompts/Builders/ChatPromptBuilder.php +++ b/src/Prompts/Builders/ChatPromptBuilder.php @@ -16,7 +16,7 @@ class ChatPromptBuilder implements PromptBuilder use BuildsPrompts; /** - * @var \Cortex\LLM\Data\Messages\MessageCollection|array|string + * @var \Cortex\LLM\Data\Messages\MessageCollection|array|string */ protected MessageCollection|array|string $messages = []; @@ -32,7 +32,7 @@ public function build(): PromptTemplate&Pipeable } /** - * @param \Cortex\LLM\Data\Messages\MessageCollection|array|string $messages + * @param \Cortex\LLM\Data\Messages\MessageCollection|non-empty-array|string $messages */ public function messages(MessageCollection|array|string $messages): self { diff --git a/src/Prompts/Builders/Concerns/BuildsPrompts.php b/src/Prompts/Builders/Concerns/BuildsPrompts.php index c7a49a6..d7e2498 100644 --- a/src/Prompts/Builders/Concerns/BuildsPrompts.php +++ b/src/Prompts/Builders/Concerns/BuildsPrompts.php @@ -51,14 +51,22 @@ public function metadata( StructuredOutputConfig|ObjectSchema|string|null $structuredOutput = null, array $additional = [], ): self { - $this->metadata = new PromptMetadata( + return $this->setMetadata(new PromptMetadata( $provider, $model, $parameters, $tools, $structuredOutput, $additional, - ); + )); + } + + /** + * Set the metadata for the prompt. + */ + public function setMetadata(PromptMetadata $metadata): self + { + $this->metadata = $metadata; return $this; } diff --git a/src/Prompts/Contracts/PromptBuilder.php b/src/Prompts/Contracts/PromptBuilder.php index b95cba4..2698dc2 100644 --- a/src/Prompts/Contracts/PromptBuilder.php +++ b/src/Prompts/Contracts/PromptBuilder.php @@ -4,10 +4,35 @@ namespace Cortex\Prompts\Contracts; +use Closure; +use Cortex\Pipeline; +use Cortex\LLM\Contracts\LLM; +use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\LLM\Data\StructuredOutputConfig; + interface PromptBuilder { /** * Build the prompt template. */ public function build(): PromptTemplate; + + /** + * Convenience method to build and pipe the prompt template to an LLM. + */ + public function llm(LLM|string|null $provider = null, Closure|string|null $model = null): Pipeline; + + /** + * @param array $parameters + * @param array $tools + * @param array $additional + */ + public function metadata( + ?string $provider = null, + ?string $model = null, + array $parameters = [], + array $tools = [], + StructuredOutputConfig|ObjectSchema|string|null $structuredOutput = null, + array $additional = [], + ): self; } diff --git a/src/Prompts/Contracts/PromptFactory.php b/src/Prompts/Contracts/PromptFactory.php new file mode 100644 index 0000000..207ee79 --- /dev/null +++ b/src/Prompts/Contracts/PromptFactory.php @@ -0,0 +1,15 @@ + $options + */ + public function make(string $name, array $options = []): PromptTemplate; +} diff --git a/src/Prompts/Enums/PromptType.php b/src/Prompts/Enums/PromptType.php new file mode 100644 index 0000000..0dd8d8a --- /dev/null +++ b/src/Prompts/Enums/PromptType.php @@ -0,0 +1,28 @@ + new ChatPromptBuilder(), + self::Text => new TextPromptBuilder(), + }; + } +} diff --git a/src/Prompts/Factories/LangfusePromptFactory.php b/src/Prompts/Factories/LangfusePromptFactory.php new file mode 100644 index 0000000..d9b0173 --- /dev/null +++ b/src/Prompts/Factories/LangfusePromptFactory.php @@ -0,0 +1,225 @@ +, + * name: string, + * version: int, + * config: array, + * labels: array, + * tags: array, + * commitMessage?: string|null, + * resolutionGraph?: object|null + * } + * @phpstan-type LangfuseTextPrompt array{ + * type: 'text', + * prompt: string, + * name: string, + * version: int, + * config: array, + * labels: array, + * tags: array, + * commitMessage?: string|null, + * resolutionGraph?: object|null + * } + * @phpstan-type LangfusePromptResponse LangfuseChatPrompt|LangfuseTextPrompt + */ +class LangfusePromptFactory implements PromptFactory +{ + use DiscoversPsrImplementations; + + /** + * @param \Closure(array): \Cortex\Prompts\Data\PromptMetadata $metadataResolver + */ + public function __construct( + #[SensitiveParameter] + protected string $username, + #[SensitiveParameter] + protected string $password, + protected string $baseUrl = 'https://cloud.langfuse.com', + protected ?Closure $metadataResolver = null, + protected ?ClientInterface $httpClient = null, + protected ?CacheInterface $cache = null, + protected int $cacheTtl = 3600, + ) {} + + /** + * @param array{version?: int, label?: string} $options + */ + public function make(string $name, array $options = []): PromptTemplate + { + $data = $this->getResponseContent($name, $options); + + $builder = match ($data['type']) { + 'chat' => $this->buildChatPrompt($data), + 'text' => $this->buildTextPrompt($data), // @phpstan-ignore match.alwaysTrue + default => throw new PromptException('Unsupported prompt type: ' . $data['type']), + }; + + $metadataResolver = $this->metadataResolver ?? static::defaultMetadataResolver(); + + $builder->setMetadata($metadataResolver($data['config'])); + + return $builder->build(); + } + + /** + * @param \Closure(array): \Cortex\Prompts\Data\PromptMetadata $resolver + */ + public function resolveMetadataUsing(Closure $resolver): self + { + $this->metadataResolver = $resolver; + + return $this; + } + + /** + * @param LangfuseChatPrompt $data + */ + protected function buildChatPrompt(array $data): ChatPromptBuilder + { + $builder = new ChatPromptBuilder(); + $messages = array_map(function (array $message): Message|MessagePlaceholder { + return match ($message['type']) { + 'chatmessage' => match ($message['role']) { + 'user' => new UserMessage($message['content']), + 'assistant' => new AssistantMessage($message['content']), + 'system' => new SystemMessage($message['content']), + default => throw new PromptException('Unsupported message role: ' . $message['role']), + }, + 'placeholder' => new MessagePlaceholder($message['name']), // @phpstan-ignore match.alwaysTrue + default => throw new PromptException('Unsupported message type: ' . $message['type']), + }; + }, $data['prompt']); + + return $builder->messages($messages); + } + + /** + * @param LangfuseTextPrompt $data + */ + protected function buildTextPrompt(array $data): TextPromptBuilder + { + $builder = new TextPromptBuilder(); + + return $builder->text($data['prompt']); + } + + /** + * @param array{version?: int, label?: string} $options + * + * @return LangfusePromptResponse + */ + protected function getResponseContent(string $name, array $options = []): array + { + $params = []; + + if (isset($options['version'])) { + $params['version'] = $options['version']; + } + + if (isset($options['label'])) { + $params['label'] = $options['label']; + } + + $uri = sprintf('%s/api/public/v2/prompts/%s', $this->baseUrl, $name); + + if ($params !== []) { + $uri .= '?' . http_build_query($params); + } + + $cache = $this->cache ?? $this->discoverCache(); + + if ($cache !== null) { + $cacheKey = 'cortex.prompts.langfuse.' . hash('sha256', $uri); + + $result = $cache->get($cacheKey); + + if ($result !== null) { + return $result; + } + } + + $client = $this->httpClient ?? $this->discoverHttpClientOrFail(); + + $response = $client->sendRequest( + $this->discoverHttpRequestFactoryOrFail() + ->createRequest('GET', $uri) + ->withHeader( + 'Authorization', + sprintf('Basic %s', base64_encode($this->username . ':' . $this->password)), + ), + ); + + try { + $result = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new PromptException('Invalid JSON response from Langfuse: ' . $e->getMessage()); + } + + if ($cache !== null) { + $cache->set($cacheKey, $result, $this->cacheTtl); + } + + return $result; + } + + /** + * @return \Closure(array): \Cortex\Prompts\Data\PromptMetadata + */ + protected static function defaultMetadataResolver(): Closure + { + return function (array $config): PromptMetadata { + $structuredOutput = $config['structuredOutput'] ?? null; + + if (is_string($structuredOutput) || is_array($structuredOutput)) { + $structuredOutput = SchemaFactory::fromJson($structuredOutput); + } + + return new PromptMetadata( + $config['provider'] ?? null, + $config['model'] ?? null, + $config['parameters'] ?? [], + $config['tools'] ?? [], + $structuredOutput, + $config['additional'] ?? [], + ); + }; + } +} diff --git a/src/Prompts/Factories/McpPromptFactory.php b/src/Prompts/Factories/McpPromptFactory.php new file mode 100644 index 0000000..ddc953c --- /dev/null +++ b/src/Prompts/Factories/McpPromptFactory.php @@ -0,0 +1,25 @@ + $type + * + * @return ($type is 'chat' ? \Cortex\Prompts\Builders\ChatPromptBuilder : \Cortex\Prompts\Builders\TextPromptBuilder) + */ + public static function builder(string $type = 'chat'): PromptBuilder + { + return PromptType::from($type)->builder(); + } + + /** + * Get the prompt factory for the given driver. + */ + public static function factory(?string $driver = null): PromptFactoryContract + { + return PromptFactory::driver($driver); + } +} diff --git a/src/Prompts/PromptFactoryManager.php b/src/Prompts/PromptFactoryManager.php new file mode 100644 index 0000000..8ba1667 --- /dev/null +++ b/src/Prompts/PromptFactoryManager.php @@ -0,0 +1,41 @@ +config->get('cortex.prompt_factory.default'); + } + + public function createLangfuseDriver(): LangfusePromptFactory + { + /** @var array{username?: string, password?: string, base_uri?: string, cache?: array{store?: ?string, ttl?: int}} $config */ + $config = $this->config->get('cortex.prompt_factory.langfuse'); + + return new LangfusePromptFactory( + $config['username'] ?? '', + $config['password'] ?? '', + $config['base_uri'] ?? 'https://cloud.langfuse.com', + cache: $this->container->make('cache')->store($config['cache']['store'] ?? null), + cacheTtl: $config['cache']['ttl'] ?? 3600, + ); + } + + public function createMcpDriver(): McpPromptFactory + { + /** @var array{base_uri?: string} $config */ + $config = $this->config->get('cortex.prompt_factory.mcp'); + + return new McpPromptFactory( + $config['base_uri'] ?? 'http://localhost:3000', + ); + } +} diff --git a/src/Prompts/Templates/ChatPromptTemplate.php b/src/Prompts/Templates/ChatPromptTemplate.php index 6b9d63e..b7d4451 100644 --- a/src/Prompts/Templates/ChatPromptTemplate.php +++ b/src/Prompts/Templates/ChatPromptTemplate.php @@ -20,7 +20,7 @@ class ChatPromptTemplate extends AbstractPromptTemplate public MessageCollection $messages; /** - * @param MessageCollection|non-empty-array|string $messages + * @param MessageCollection|array|string $messages * @param array $initialVariables */ public function __construct( diff --git a/src/Support/Traits/DiscoversPsrImplementations.php b/src/Support/Traits/DiscoversPsrImplementations.php index ff08707..e6592f4 100644 --- a/src/Support/Traits/DiscoversPsrImplementations.php +++ b/src/Support/Traits/DiscoversPsrImplementations.php @@ -15,9 +15,14 @@ trait DiscoversPsrImplementations { - protected function discoverHttpClient(bool $singleton = true): ClientInterface + protected function discoverHttpClient(bool $singleton = true): ?ClientInterface { - $client = Discover::httpClient($singleton); + return Discover::httpClient($singleton); + } + + protected function discoverHttpClientOrFail(bool $singleton = true): ClientInterface + { + $client = $this->discoverHttpClient($singleton); if (! $client instanceof ClientInterface) { throw new RuntimeException('HTTP client not found'); @@ -26,9 +31,14 @@ protected function discoverHttpClient(bool $singleton = true): ClientInterface return $client; } - protected function discoverHttpRequestFactory(bool $singleton = true): RequestFactoryInterface + protected function discoverHttpRequestFactory(bool $singleton = true): ?RequestFactoryInterface { - $requestFactory = Discover::httpRequestFactory($singleton); + return Discover::httpRequestFactory($singleton); + } + + protected function discoverHttpRequestFactoryOrFail(bool $singleton = true): RequestFactoryInterface + { + $requestFactory = $this->discoverHttpRequestFactory($singleton); if (! $requestFactory instanceof RequestFactoryInterface) { throw new RuntimeException('HTTP request factory not found'); @@ -37,9 +47,14 @@ protected function discoverHttpRequestFactory(bool $singleton = true): RequestFa return $requestFactory; } - protected function discoverHttpUriFactory(bool $singleton = true): UriFactoryInterface + protected function discoverHttpUriFactory(bool $singleton = true): ?UriFactoryInterface + { + return Discover::httpUriFactory($singleton); + } + + protected function discoverHttpUriFactoryOrFail(bool $singleton = true): UriFactoryInterface { - $uriFactory = Discover::httpUriFactory($singleton); + $uriFactory = $this->discoverHttpUriFactory($singleton); if (! $uriFactory instanceof UriFactoryInterface) { throw new RuntimeException('HTTP URI factory not found'); @@ -48,9 +63,14 @@ protected function discoverHttpUriFactory(bool $singleton = true): UriFactoryInt return $uriFactory; } - protected function discoverHttpStreamFactory(bool $singleton = true): StreamFactoryInterface + protected function discoverHttpStreamFactory(bool $singleton = true): ?StreamFactoryInterface { - $streamFactory = Discover::httpStreamFactory($singleton); + return Discover::httpStreamFactory($singleton); + } + + protected function discoverHttpStreamFactoryOrFail(bool $singleton = true): StreamFactoryInterface + { + $streamFactory = $this->discoverHttpStreamFactory($singleton); if (! $streamFactory instanceof StreamFactoryInterface) { throw new RuntimeException('HTTP stream factory not found'); @@ -59,9 +79,14 @@ protected function discoverHttpStreamFactory(bool $singleton = true): StreamFact return $streamFactory; } - protected function discoverEventDispatcher(bool $singleton = true): EventDispatcherInterface + protected function discoverEventDispatcher(bool $singleton = true): ?EventDispatcherInterface { - $eventDispatcher = Discover::eventDispatcher($singleton); + return Discover::eventDispatcher($singleton); + } + + protected function discoverEventDispatcherOrFail(bool $singleton = true): EventDispatcherInterface + { + $eventDispatcher = $this->discoverEventDispatcher($singleton); if (! $eventDispatcher instanceof EventDispatcherInterface) { throw new RuntimeException('Event dispatcher not found'); @@ -70,9 +95,15 @@ protected function discoverEventDispatcher(bool $singleton = true): EventDispatc return $eventDispatcher; } - protected function discoverCache(bool $singleton = true): CacheInterface + protected function discoverCache(bool $singleton = true): ?CacheInterface + { + // @phpstan-ignore return.type + return Discover::cache($singleton); + } + + protected function discoverCacheOrFail(bool $singleton = true): CacheInterface { - $cache = Discover::cache($singleton); + $cache = $this->discoverCache($singleton); if (! $cache instanceof CacheInterface) { throw new RuntimeException('Cache not found'); diff --git a/src/Support/Utils.php b/src/Support/Utils.php index d8d5c28..24b91ad 100644 --- a/src/Support/Utils.php +++ b/src/Support/Utils.php @@ -69,7 +69,7 @@ public static function toToolCollection(array $tools): Collection /** * Ensure that the given messages are a MessageCollection. * - * @param \Cortex\LLM\Data\Messages\MessageCollection|\Cortex\LLM\Contracts\Message|array|string $messages + * @param \Cortex\LLM\Data\Messages\MessageCollection|\Cortex\LLM\Contracts\Message|non-empty-array|string $messages * * @return \Cortex\LLM\Data\Messages\MessageCollection */ diff --git a/src/Tasks/Builders/StructuredTaskBuilder.php b/src/Tasks/Builders/StructuredTaskBuilder.php index f7c576c..ffa6c07 100644 --- a/src/Tasks/Builders/StructuredTaskBuilder.php +++ b/src/Tasks/Builders/StructuredTaskBuilder.php @@ -172,12 +172,12 @@ public function build(): Task return new StructuredOutputTask( name: $this->name, - description: $this->description, - messages: $this->messages, schema: $this->schema, llm: $this->llm ?? LLM::provider(), - initialPromptVariables: $this->initialPromptVariables, + messages: $this->messages, + description: $this->description, outputParser: $this->getOutputParser(), + initialPromptVariables: $this->initialPromptVariables, outputMode: $this->outputMode, tools: $this->tools, toolChoice: $this->toolChoice, diff --git a/src/Tasks/Builders/TextTaskBuilder.php b/src/Tasks/Builders/TextTaskBuilder.php index 9b50a9d..56056e5 100644 --- a/src/Tasks/Builders/TextTaskBuilder.php +++ b/src/Tasks/Builders/TextTaskBuilder.php @@ -40,13 +40,13 @@ public function build(): Task return new TextOutputTask( name: $this->name, - description: $this->description, - messages: $this->messages, llm: $this->llm ?? LLM::provider(), + messages: $this->messages, + description: $this->description, + outputParser: $this->getOutputParser(), initialPromptVariables: $this->initialPromptVariables, tools: $this->tools, toolChoice: $this->toolChoice, - outputParser: $this->getOutputParser(), maxIterations: $this->maxIterations, ); } diff --git a/tests/Unit/Experimental/PlaygroundTest.php b/tests/Unit/Experimental/PlaygroundTest.php index 6914ee5..5557f7d 100644 --- a/tests/Unit/Experimental/PlaygroundTest.php +++ b/tests/Unit/Experimental/PlaygroundTest.php @@ -11,13 +11,13 @@ use Cortex\Facades\ModelInfo; use Cortex\Memory\ChatMemory; use Cortex\Facades\Embeddings; +use Cortex\Events\ChatModelEnd; use Cortex\Facades\VectorStore; use Cortex\Tasks\Enums\TaskType; use Cortex\Events\ChatModelStart; use Cortex\JsonSchema\SchemaFactory; use Cortex\Memory\ChatSummaryMemory; use Illuminate\Support\Facades\Event; -use Cortex\ModelInfo\Enums\ModelFeature; use Cortex\JsonSchema\Types\StringSchema; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\ModelInfo\Enums\ModelProvider; @@ -113,9 +113,9 @@ dump($event->parameters); }); - // Event::listen(ChatModelEnd::class, function (ChatModelEnd $event): void { - // dump($event->result->generation->message->toArray()); - // }); + Event::listen(ChatModelEnd::class, function (ChatModelEnd $event): void { + dump($event->result); + }); // $generateStoryIdea = task('generate_story_idea', TaskType::Structured) // // ->llm('ollama', 'qwen2.5:14b') @@ -124,19 +124,28 @@ // ->user('Generate a story idea about {topic}. Only answer in a single sentence.') // ->properties(new StringSchema('story_idea')); - $generateStoryIdea = Cortex::prompt([ + $prompt = Cortex::prompt([ new SystemMessage('You are an expert story ideator.'), new UserMessage('Generate a story idea about {topic}. Only answer in a single sentence.'), - ]) - ->llm('github', function (LLMContract $llm): LLMContract { - return $llm->withFeatures(ModelFeature::ToolCalling, ModelFeature::StructuredOutput, ModelFeature::JsonOutput) - // ->withModel('xai/grok-3-mini') - // ->withModel('mistral-small3.1') - ->withStructuredOutput( - output: SchemaFactory::object()->properties(SchemaFactory::string('story_idea')->required()), - outputMode: StructuredOutputMode::Auto, - ); - }); + ]); + + $generateStoryIdea = $prompt->llm('openai', function (LLMContract $llm): LLMContract { + return $llm->withModel('gpt-5-mini') + ->withStructuredOutput( + output: SchemaFactory::object()->properties(SchemaFactory::string('story_idea')->required()), + outputMode: StructuredOutputMode::Auto, + ); + }); + + // $generateStoryIdea = $prompt->llm('github', function (LLMContract $llm): LLMContract { + // return $llm->withFeatures(ModelFeature::ToolCalling, ModelFeature::StructuredOutput, ModelFeature::JsonOutput) + // // ->withModel('xai/grok-3-mini') + // // ->withModel('mistral-small3.1') + // ->withStructuredOutput( + // output: SchemaFactory::object()->properties(SchemaFactory::string('story_idea')->required()), + // outputMode: StructuredOutputMode::Auto, + // ); + // }); // dd($generateStoryIdea->invoke([ // 'topic' => 'a dragon', diff --git a/tests/Unit/OutputParsers/EnumOutputParserTest.php b/tests/Unit/OutputParsers/EnumOutputParserTest.php index 81f511a..8315a84 100644 --- a/tests/Unit/OutputParsers/EnumOutputParserTest.php +++ b/tests/Unit/OutputParsers/EnumOutputParserTest.php @@ -16,7 +16,7 @@ $parser = new EnumOutputParser('NonExistentEnum'); expect(fn(): BackedEnum => $parser->parse('one')) - ->toThrow(OutputParserException::class, 'Enum NonExistentEnum does not exist'); + ->toThrow(OutputParserException::class, 'Invalid enum: NonExistentEnum'); }); test('parses string input directly to enum', function (): void { diff --git a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php new file mode 100644 index 0000000..f6ddb36 --- /dev/null +++ b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php @@ -0,0 +1,599 @@ + HandlerStack::create(new MockHandler($responses)), + ]), + cache: $cache, + cacheTtl: $cacheTtl, + ); +} + +test('it can create a chat prompt template from langfuse', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-chat-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'chatmessage', + 'role' => 'system', + 'content' => 'You are a helpful assistant.', + ], + [ + 'type' => 'chatmessage', + 'role' => 'user', + 'content' => 'What is the capital of {{country}}?', + ], + [ + 'type' => 'placeholder', + 'name' => 'history', + ], + ], + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->make('test-chat-prompt'); + + expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); + expect($prompt->messages)->toHaveCount(3); + expect($prompt->messages[0])->toBeInstanceOf(SystemMessage::class); + expect($prompt->messages[0]->content)->toBe('You are a helpful assistant.'); + expect($prompt->messages[1])->toBeInstanceOf(UserMessage::class); + expect($prompt->messages[1]->content)->toBe('What is the capital of {{country}}?'); + expect($prompt->messages[2])->toBeInstanceOf(MessagePlaceholder::class); + expect($prompt->messages[2]->name)->toBe('history'); +}); + +test('it can create a text prompt template from langfuse', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-text-prompt', + 'version' => 2, + 'config' => [], + 'labels' => [ + 'production', + ], + 'tags' => [], + 'prompt' => 'Write a story about {{topic}}. Make it {{length}} words long.', + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->make('test-text-prompt', [ + 'version' => 2, + 'label' => 'production', + ]); + + expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); + expect($prompt->text)->toBe('Write a story about {{topic}}. Make it {{length}} words long.'); +}); + +test('it handles assistant messages in chat prompts', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-assistant-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'chatmessage', + 'role' => 'user', + 'content' => 'Hello!', + ], + [ + 'type' => 'chatmessage', + 'role' => 'assistant', + 'content' => 'Hi there! How can I help you?', + ], + ], + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->make('test-assistant-prompt'); + + expect($prompt->messages[0])->toBeInstanceOf(UserMessage::class); + expect($prompt->messages[1])->toBeInstanceOf(AssistantMessage::class); + expect($prompt->messages[1]->content)->toBe('Hi there! How can I help you?'); +}); + +test('it throws exception for unsupported prompt type', function (): void { + $responseData = [ + 'type' => 'unsupported', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Some content', + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + expect(fn(): PromptTemplate => $factory->make('test-prompt')) + ->toThrow(PromptException::class, 'Unsupported prompt type: unsupported'); +}); + +test('it throws exception for unsupported message role', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'chatmessage', + 'role' => 'unknown', + 'content' => 'Hello!', + ], + ], + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + expect(fn(): PromptTemplate => $factory->make('test-prompt')) + ->toThrow(PromptException::class, 'Unsupported message role: unknown'); +}); + +test('it throws exception for unsupported message type', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'unknown', + 'content' => 'Hello!', + ], + ], + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + expect(fn(): PromptTemplate => $factory->make('test-prompt')) + ->toThrow(PromptException::class, 'Unsupported message type: unknown'); +}); + +test('it builds correct url with version and label parameters', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt', + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->make('test-prompt'); + + expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); +}); + +test('it handles http errors that return invalid json', function (): void { + $factory = createLangfuseFactory([ + new Response(404, body: 'Prompt not found'), + ]); + + expect(fn(): PromptTemplate => $factory->make('nonexistent-prompt')) + ->toThrow(PromptException::class, 'Invalid JSON response from Langfuse: Syntax error'); +}); + +test('it handles malformed json response', function (): void { + $factory = createLangfuseFactory([ + new Response(200, body: 'invalid json {'), + ]); + + expect(fn(): PromptTemplate => $factory->make('test-prompt')) + ->toThrow(PromptException::class, 'Invalid JSON response from Langfuse: Syntax error'); +}); + +test('it sets metadata from config using default resolver', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [ + 'provider' => 'openai', + 'model' => 'gpt-4', + 'parameters' => [ + 'temperature' => 0.7, + 'max_tokens' => 100, + ], + 'tools' => ['calculator', 'search'], + 'structuredOutput' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + ], + 'required' => ['name'], + ], + 'additional' => [ + 'custom_field' => 'custom_value', + ], + ], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt with metadata', + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + /** @var \Cortex\Prompts\Templates\TextPromptTemplate $prompt */ + $prompt = $factory->make('test-prompt'); + + expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); + expect($prompt->metadata)->not()->toBeNull(); + expect($prompt->metadata->provider)->toBe('openai'); + expect($prompt->metadata->model)->toBe('gpt-4'); + expect($prompt->metadata->parameters)->toBe([ + 'temperature' => 0.7, + 'max_tokens' => 100, + ]); + expect($prompt->metadata->tools)->toBe(['calculator', 'search']); + expect($prompt->metadata->structuredOutput)->toBeInstanceOf(ObjectSchema::class); + expect($prompt->metadata->structuredOutput->getPropertyKeys())->toBe(['name']); + expect($prompt->metadata->additional)->toBe([ + 'custom_field' => 'custom_value', + ]); +}); + +test('it sets metadata from empty config using default resolver', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'chatmessage', + 'role' => 'user', + 'content' => 'Hello!', + ], + ], + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->make('test-prompt'); + + expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); + expect($prompt->metadata)->not()->toBeNull(); + expect($prompt->metadata->provider)->toBeNull(); + expect($prompt->metadata->model)->toBeNull(); + expect($prompt->metadata->parameters)->toBe([]); + expect($prompt->metadata->tools)->toBe([]); + expect($prompt->metadata->structuredOutput)->toBeNull(); + expect($prompt->metadata->additional)->toBe([]); +}); + +test('it sets metadata from partial config using default resolver', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [ + 'provider' => 'anthropic', + 'parameters' => [ + 'temperature' => 0.5, + ], + // missing model, tools, structuredOutput, additional + ], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt', + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->make('test-prompt'); + + expect($prompt->metadata)->not()->toBeNull(); + expect($prompt->metadata->provider)->toBe('anthropic'); + expect($prompt->metadata->model)->toBeNull(); + expect($prompt->metadata->parameters)->toBe([ + 'temperature' => 0.5, + ]); + expect($prompt->metadata->tools)->toBe([]); + expect($prompt->metadata->structuredOutput)->toBeNull(); + expect($prompt->metadata->additional)->toBe([]); +}); + +test('it uses custom metadata resolver when provided', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [ + 'llm_provider' => 'custom-provider', + 'llm_model' => 'custom-model', + 'extra_data' => 'some value', + ], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt', + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + // Set a custom metadata resolver + $factory->resolveMetadataUsing(function (array $config): PromptMetadata { + return new PromptMetadata( + provider: $config['llm_provider'] ?? null, + model: $config['llm_model'] ?? null, + parameters: [ + 'custom' => true, + ], + tools: [], + structuredOutput: null, + additional: [ + 'extra' => $config['extra_data'] ?? null, + ], + ); + }); + + $prompt = $factory->make('test-prompt'); + + expect($prompt->metadata)->not()->toBeNull(); + expect($prompt->metadata->provider)->toBe('custom-provider'); + expect($prompt->metadata->model)->toBe('custom-model'); + expect($prompt->metadata->parameters)->toBe([ + 'custom' => true, + ]); + expect($prompt->metadata->tools)->toBe([]); + expect($prompt->metadata->structuredOutput)->toBeNull(); + expect($prompt->metadata->additional)->toBe([ + 'extra' => 'some value', + ]); +}); + +test('it returns same factory instance when setting custom metadata resolver', function (): void { + $factory = createLangfuseFactory([]); + + $resolver = fn(array $config): PromptMetadata => new PromptMetadata(); + $result = $factory->resolveMetadataUsing($resolver); + + expect($result)->toBe($factory); +}); + +test('it sets metadata on chat prompt template', function (): void { + $responseData = [ + 'type' => 'chat', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [ + 'provider' => 'openai', + 'model' => 'gpt-3.5-turbo', + ], + 'labels' => [], + 'tags' => [], + 'prompt' => [ + [ + 'type' => 'chatmessage', + 'role' => 'system', + 'content' => 'You are helpful.', + ], + ], + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); + + $prompt = $factory->make('test-prompt'); + + expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); + expect($prompt->metadata)->not()->toBeNull(); + expect($prompt->metadata->provider)->toBe('openai'); + expect($prompt->metadata->model)->toBe('gpt-3.5-turbo'); +}); + +test('it caches prompt responses and reuses them on subsequent calls', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'cached-prompt', + 'version' => 1, + 'config' => [ + 'provider' => 'openai', + 'model' => 'gpt-4', + ], + 'labels' => [], + 'tags' => [], + 'prompt' => 'This is a cached prompt', + ]; + + /** @var CacheInterface&\Mockery\MockInterface $cache */ + $cache = mock(CacheInterface::class); + + // First call: cache miss, then cache set + $cache->shouldReceive('get') + ->once() + ->with(Mockery::type('string')) + ->andReturn(null); + + $cache->shouldReceive('set') + ->once() + ->with(Mockery::type('string'), $responseData, 3600) + ->andReturnTrue(); + + // Second call: cache hit + $cache->shouldReceive('get') + ->once() + ->with(Mockery::type('string')) + ->andReturn($responseData); + + $factory = createLangfuseFactory( + [new Response(200, body: json_encode($responseData))], + $cache, + 3600, + ); + + // First call - should hit HTTP client and cache the result + $prompt1 = $factory->make('cached-prompt'); + + expect($prompt1)->toBeInstanceOf(TextPromptTemplate::class); + expect($prompt1->text)->toBe('This is a cached prompt'); + expect($prompt1->metadata->provider)->toBe('openai'); + expect($prompt1->metadata->model)->toBe('gpt-4'); + + // Second call with same parameters - should use cache, not HTTP + $prompt2 = $factory->make('cached-prompt'); + + expect($prompt2)->toBeInstanceOf(TextPromptTemplate::class); + expect($prompt2->text)->toBe('This is a cached prompt'); + expect($prompt2->metadata->provider)->toBe('openai'); + expect($prompt2->metadata->model)->toBe('gpt-4'); + + // Verify both prompts are equivalent + expect($prompt1->text)->toBe($prompt2->text); + expect($prompt1->metadata->provider)->toBe($prompt2->metadata->provider); + expect($prompt1->metadata->model)->toBe($prompt2->metadata->model); +}); + +test('it generates correct cache keys for different prompt parameters', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt', + ]; + + $cacheKeys = []; + /** @var CacheInterface&\Mockery\MockInterface $cache */ + $cache = mock(CacheInterface::class); + + // Mock cache to capture the keys used and always return null (cache miss) + $cache->shouldReceive('get') + ->times(3) + ->with(Mockery::on(function ($key) use (&$cacheKeys): bool { + $cacheKeys[] = $key; + + return is_string($key); + })) + ->andReturn(null); + + $cache->shouldReceive('set') + ->times(3) + ->with(Mockery::type('string'), $responseData, Mockery::any()) + ->andReturnTrue(); + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + new Response(200, body: json_encode($responseData)), + new Response(200, body: json_encode($responseData)), + ], $cache); + + // Call with different parameters to generate different cache keys + $factory->make('test-prompt'); + $factory->make('test-prompt', [ + 'version' => 2, + ]); + $factory->make('test-prompt', [ + 'label' => 'production', + ]); + + expect($cacheKeys)->toHaveCount(3); + + // Verify all cache keys are different (different URLs generate different hashes) + expect($cacheKeys[0])->not()->toBe($cacheKeys[1]); + expect($cacheKeys[1])->not()->toBe($cacheKeys[2]); + expect($cacheKeys[0])->not()->toBe($cacheKeys[2]); + + // Verify all keys start with the expected prefix + foreach ($cacheKeys as $key) { + expect($key)->toStartWith('cortex.prompts.langfuse.'); + } +}); + +test('it works without cache when none is provided', function (): void { + $responseData = [ + 'type' => 'text', + 'name' => 'test-prompt', + 'version' => 1, + 'config' => [], + 'labels' => [], + 'tags' => [], + 'prompt' => 'Test prompt without cache', + ]; + + $factory = createLangfuseFactory([ + new Response(200, body: json_encode($responseData)), + ]); // No cache provided - should use PSR discovery + + $prompt = $factory->make('test-prompt'); + + expect($prompt)->toBeInstanceOf(TextPromptTemplate::class); + expect($prompt->text)->toBe('Test prompt without cache'); +});