Skip to content

Commit f340de8

Browse files
committed
add caching
1 parent 819b027 commit f340de8

File tree

5 files changed

+224
-17
lines changed

5 files changed

+224
-17
lines changed

config/cortex.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,12 @@
210210
'username' => env('LANGFUSE_USERNAME', ''),
211211
'password' => env('LANGFUSE_PASSWORD', ''),
212212
'base_uri' => env('LANGFUSE_BASE_URI', 'https://cloud.langfuse.com'),
213+
214+
'cache' => [
215+
'enabled' => env('CORTEX_PROMPT_CACHE_ENABLED', false),
216+
'store' => env('CORTEX_PROMPT_CACHE_STORE', null),
217+
'ttl' => env('CORTEX_PROMPT_CACHE_TTL', 3600),
218+
],
213219
],
214220

215221
// 'mcp' => [

src/Prompts/Factories/LangfusePromptFactory.php

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use JsonException;
99
use SensitiveParameter;
1010
use Cortex\LLM\Contracts\Message;
11+
use Psr\SimpleCache\CacheInterface;
1112
use Cortex\JsonSchema\SchemaFactory;
1213
use Psr\Http\Client\ClientInterface;
1314
use Cortex\Exceptions\PromptException;
@@ -72,8 +73,10 @@ public function __construct(
7273
#[SensitiveParameter]
7374
protected string $password,
7475
protected string $baseUrl = 'https://cloud.langfuse.com',
75-
protected ?ClientInterface $httpClient = null,
7676
protected ?Closure $metadataResolver = null,
77+
protected ?ClientInterface $httpClient = null,
78+
protected ?CacheInterface $cache = null,
79+
protected int $cacheTtl = 3600,
7780
) {}
7881

7982
/**
@@ -161,10 +164,22 @@ protected function getResponseContent(string $name, array $options = []): array
161164
$uri .= '?' . http_build_query($params);
162165
}
163166

164-
$client = $this->httpClient ?? $this->discoverHttpClient();
167+
$cache = $this->cache ?? $this->discoverCache();
168+
169+
if ($cache !== null) {
170+
$cacheKey = 'cortex.prompts.langfuse.' . hash('sha256', $uri);
171+
172+
$result = $cache->get($cacheKey);
173+
174+
if ($result !== null) {
175+
return $result;
176+
}
177+
}
178+
179+
$client = $this->httpClient ?? $this->discoverHttpClientOrFail();
165180

166181
$response = $client->sendRequest(
167-
$this->discoverHttpRequestFactory()
182+
$this->discoverHttpRequestFactoryOrFail()
168183
->createRequest('GET', $uri)
169184
->withHeader(
170185
'Authorization',
@@ -173,10 +188,16 @@ protected function getResponseContent(string $name, array $options = []): array
173188
);
174189

175190
try {
176-
return json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
191+
$result = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
177192
} catch (JsonException $e) {
178193
throw new PromptException('Invalid JSON response from Langfuse: ' . $e->getMessage());
179194
}
195+
196+
if ($cache !== null) {
197+
$cache->set($cacheKey, $result, $this->cacheTtl);
198+
}
199+
200+
return $result;
180201
}
181202

182203
/**

src/Prompts/PromptFactoryManager.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@ public function getDefaultDriver(): string
1717

1818
public function createLangfuseDriver(): LangfusePromptFactory
1919
{
20-
/** @var array{username?: string, password?: string, base_uri?: string} $config */
20+
/** @var array{username?: string, password?: string, base_uri?: string, cache_store?: ?string, cache_ttl?: int} $config */
2121
$config = $this->config->get('cortex.prompt_factory.langfuse');
2222

2323
return new LangfusePromptFactory(
2424
$config['username'] ?? '',
2525
$config['password'] ?? '',
2626
$config['base_uri'] ?? 'https://cloud.langfuse.com',
27+
cache: $this->container->make('cache')->store($config['cache_store'] ?? null),
28+
cacheTtl: $config['cache_ttl'] ?? 3600,
2729
);
2830
}
2931

src/Support/Traits/DiscoversPsrImplementations.php

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@
1515

1616
trait DiscoversPsrImplementations
1717
{
18-
protected function discoverHttpClient(bool $singleton = true): ClientInterface
18+
protected function discoverHttpClient(bool $singleton = true): ?ClientInterface
1919
{
20-
$client = Discover::httpClient($singleton);
20+
return Discover::httpClient($singleton);
21+
}
22+
23+
protected function discoverHttpClientOrFail(bool $singleton = true): ClientInterface
24+
{
25+
$client = $this->discoverHttpClient($singleton);
2126

2227
if (! $client instanceof ClientInterface) {
2328
throw new RuntimeException('HTTP client not found');
@@ -26,9 +31,14 @@ protected function discoverHttpClient(bool $singleton = true): ClientInterface
2631
return $client;
2732
}
2833

29-
protected function discoverHttpRequestFactory(bool $singleton = true): RequestFactoryInterface
34+
protected function discoverHttpRequestFactory(bool $singleton = true): ?RequestFactoryInterface
3035
{
31-
$requestFactory = Discover::httpRequestFactory($singleton);
36+
return Discover::httpRequestFactory($singleton);
37+
}
38+
39+
protected function discoverHttpRequestFactoryOrFail(bool $singleton = true): RequestFactoryInterface
40+
{
41+
$requestFactory = $this->discoverHttpRequestFactory($singleton);
3242

3343
if (! $requestFactory instanceof RequestFactoryInterface) {
3444
throw new RuntimeException('HTTP request factory not found');
@@ -37,9 +47,14 @@ protected function discoverHttpRequestFactory(bool $singleton = true): RequestFa
3747
return $requestFactory;
3848
}
3949

40-
protected function discoverHttpUriFactory(bool $singleton = true): UriFactoryInterface
50+
protected function discoverHttpUriFactory(bool $singleton = true): ?UriFactoryInterface
51+
{
52+
return Discover::httpUriFactory($singleton);
53+
}
54+
55+
protected function discoverHttpUriFactoryOrFail(bool $singleton = true): UriFactoryInterface
4156
{
42-
$uriFactory = Discover::httpUriFactory($singleton);
57+
$uriFactory = $this->discoverHttpUriFactory($singleton);
4358

4459
if (! $uriFactory instanceof UriFactoryInterface) {
4560
throw new RuntimeException('HTTP URI factory not found');
@@ -48,9 +63,14 @@ protected function discoverHttpUriFactory(bool $singleton = true): UriFactoryInt
4863
return $uriFactory;
4964
}
5065

51-
protected function discoverHttpStreamFactory(bool $singleton = true): StreamFactoryInterface
66+
protected function discoverHttpStreamFactory(bool $singleton = true): ?StreamFactoryInterface
5267
{
53-
$streamFactory = Discover::httpStreamFactory($singleton);
68+
return Discover::httpStreamFactory($singleton);
69+
}
70+
71+
protected function discoverHttpStreamFactoryOrFail(bool $singleton = true): StreamFactoryInterface
72+
{
73+
$streamFactory = $this->discoverHttpStreamFactory($singleton);
5474

5575
if (! $streamFactory instanceof StreamFactoryInterface) {
5676
throw new RuntimeException('HTTP stream factory not found');
@@ -59,9 +79,14 @@ protected function discoverHttpStreamFactory(bool $singleton = true): StreamFact
5979
return $streamFactory;
6080
}
6181

62-
protected function discoverEventDispatcher(bool $singleton = true): EventDispatcherInterface
82+
protected function discoverEventDispatcher(bool $singleton = true): ?EventDispatcherInterface
6383
{
64-
$eventDispatcher = Discover::eventDispatcher($singleton);
84+
return Discover::eventDispatcher($singleton);
85+
}
86+
87+
protected function discoverEventDispatcherOrFail(bool $singleton = true): EventDispatcherInterface
88+
{
89+
$eventDispatcher = $this->discoverEventDispatcher($singleton);
6590

6691
if (! $eventDispatcher instanceof EventDispatcherInterface) {
6792
throw new RuntimeException('Event dispatcher not found');
@@ -70,9 +95,15 @@ protected function discoverEventDispatcher(bool $singleton = true): EventDispatc
7095
return $eventDispatcher;
7196
}
7297

73-
protected function discoverCache(bool $singleton = true): CacheInterface
98+
protected function discoverCache(bool $singleton = true): ?CacheInterface
99+
{
100+
// @phpstan-ignore return.type
101+
return Discover::cache($singleton);
102+
}
103+
104+
protected function discoverCacheOrFail(bool $singleton = true): CacheInterface
74105
{
75-
$cache = Discover::cache($singleton);
106+
$cache = $this->discoverCache($singleton);
76107

77108
if (! $cache instanceof CacheInterface) {
78109
throw new RuntimeException('Cache not found');

tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
namespace Cortex\Tests\Unit\Prompts\Factories;
66

7+
use Mockery;
78
use GuzzleHttp\Client;
89
use GuzzleHttp\HandlerStack;
910
use GuzzleHttp\Psr7\Response;
1011
use GuzzleHttp\Handler\MockHandler;
12+
use Psr\SimpleCache\CacheInterface;
1113
use Cortex\Exceptions\PromptException;
1214
use Cortex\Prompts\Data\PromptMetadata;
1315
use Cortex\JsonSchema\Types\ObjectSchema;
@@ -22,13 +24,17 @@
2224

2325
function createLangfuseFactory(
2426
array $responses,
27+
?CacheInterface $cache = null,
28+
int $cacheTtl = 3600,
2529
): LangfusePromptFactory {
2630
return new LangfusePromptFactory(
2731
username: 'username',
2832
password: 'password',
2933
httpClient: new Client([
3034
'handler' => HandlerStack::create(new MockHandler($responses)),
3135
]),
36+
cache: $cache,
37+
cacheTtl: $cacheTtl,
3238
);
3339
}
3440

@@ -450,3 +456,144 @@ function createLangfuseFactory(
450456
expect($prompt->metadata->provider)->toBe('openai');
451457
expect($prompt->metadata->model)->toBe('gpt-3.5-turbo');
452458
});
459+
460+
test('it caches prompt responses and reuses them on subsequent calls', function (): void {
461+
$responseData = [
462+
'type' => 'text',
463+
'name' => 'cached-prompt',
464+
'version' => 1,
465+
'config' => [
466+
'provider' => 'openai',
467+
'model' => 'gpt-4',
468+
],
469+
'labels' => [],
470+
'tags' => [],
471+
'prompt' => 'This is a cached prompt',
472+
];
473+
474+
/** @var CacheInterface&\Mockery\MockInterface $cache */
475+
$cache = mock(CacheInterface::class);
476+
477+
// First call: cache miss, then cache set
478+
$cache->shouldReceive('get')
479+
->once()
480+
->with(Mockery::type('string'))
481+
->andReturn(null);
482+
483+
$cache->shouldReceive('set')
484+
->once()
485+
->with(Mockery::type('string'), $responseData, 3600)
486+
->andReturnTrue();
487+
488+
// Second call: cache hit
489+
$cache->shouldReceive('get')
490+
->once()
491+
->with(Mockery::type('string'))
492+
->andReturn($responseData);
493+
494+
$factory = createLangfuseFactory(
495+
[new Response(200, body: json_encode($responseData))],
496+
$cache,
497+
3600,
498+
);
499+
500+
// First call - should hit HTTP client and cache the result
501+
$prompt1 = $factory->make('cached-prompt');
502+
503+
expect($prompt1)->toBeInstanceOf(TextPromptTemplate::class);
504+
expect($prompt1->text)->toBe('This is a cached prompt');
505+
expect($prompt1->metadata->provider)->toBe('openai');
506+
expect($prompt1->metadata->model)->toBe('gpt-4');
507+
508+
// Second call with same parameters - should use cache, not HTTP
509+
$prompt2 = $factory->make('cached-prompt');
510+
511+
expect($prompt2)->toBeInstanceOf(TextPromptTemplate::class);
512+
expect($prompt2->text)->toBe('This is a cached prompt');
513+
expect($prompt2->metadata->provider)->toBe('openai');
514+
expect($prompt2->metadata->model)->toBe('gpt-4');
515+
516+
// Verify both prompts are equivalent
517+
expect($prompt1->text)->toBe($prompt2->text);
518+
expect($prompt1->metadata->provider)->toBe($prompt2->metadata->provider);
519+
expect($prompt1->metadata->model)->toBe($prompt2->metadata->model);
520+
});
521+
522+
test('it generates correct cache keys for different prompt parameters', function (): void {
523+
$responseData = [
524+
'type' => 'text',
525+
'name' => 'test-prompt',
526+
'version' => 1,
527+
'config' => [],
528+
'labels' => [],
529+
'tags' => [],
530+
'prompt' => 'Test prompt',
531+
];
532+
533+
$cacheKeys = [];
534+
/** @var CacheInterface&\Mockery\MockInterface $cache */
535+
$cache = mock(CacheInterface::class);
536+
537+
// Mock cache to capture the keys used and always return null (cache miss)
538+
$cache->shouldReceive('get')
539+
->times(3)
540+
->with(Mockery::on(function ($key) use (&$cacheKeys): bool {
541+
$cacheKeys[] = $key;
542+
543+
return is_string($key);
544+
}))
545+
->andReturn(null);
546+
547+
$cache->shouldReceive('set')
548+
->times(3)
549+
->with(Mockery::type('string'), $responseData, Mockery::any())
550+
->andReturnTrue();
551+
552+
$factory = createLangfuseFactory([
553+
new Response(200, body: json_encode($responseData)),
554+
new Response(200, body: json_encode($responseData)),
555+
new Response(200, body: json_encode($responseData)),
556+
], $cache);
557+
558+
// Call with different parameters to generate different cache keys
559+
$factory->make('test-prompt');
560+
$factory->make('test-prompt', [
561+
'version' => 2,
562+
]);
563+
$factory->make('test-prompt', [
564+
'label' => 'production',
565+
]);
566+
567+
expect($cacheKeys)->toHaveCount(3);
568+
569+
// Verify all cache keys are different (different URLs generate different hashes)
570+
expect($cacheKeys[0])->not()->toBe($cacheKeys[1]);
571+
expect($cacheKeys[1])->not()->toBe($cacheKeys[2]);
572+
expect($cacheKeys[0])->not()->toBe($cacheKeys[2]);
573+
574+
// Verify all keys start with the expected prefix
575+
foreach ($cacheKeys as $key) {
576+
expect($key)->toStartWith('cortex.prompts.langfuse.');
577+
}
578+
});
579+
580+
test('it works without cache when none is provided', function (): void {
581+
$responseData = [
582+
'type' => 'text',
583+
'name' => 'test-prompt',
584+
'version' => 1,
585+
'config' => [],
586+
'labels' => [],
587+
'tags' => [],
588+
'prompt' => 'Test prompt without cache',
589+
];
590+
591+
$factory = createLangfuseFactory([
592+
new Response(200, body: json_encode($responseData)),
593+
]); // No cache provided - should use PSR discovery
594+
595+
$prompt = $factory->make('test-prompt');
596+
597+
expect($prompt)->toBeInstanceOf(TextPromptTemplate::class);
598+
expect($prompt->text)->toBe('Test prompt without cache');
599+
});

0 commit comments

Comments
 (0)