From b3516fc09c6ad4dfad2becbf0a4cb3135be9803d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Jun 2025 16:49:00 +0000 Subject: [PATCH 1/3] Initial plan From af8b5eb33498576412b0f7d956e13795a82063ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Jun 2025 17:05:56 +0000 Subject: [PATCH 2/3] Add custom Contract service injection support for platforms Co-authored-by: chr-hertel <2852185+chr-hertel@users.noreply.github.com> --- README.md | 54 +++++++ src/Contract/ContractInterface.php | 18 +++ src/DependencyInjection/Configuration.php | 6 + src/DependencyInjection/LlmChainExtension.php | 27 ++++ .../DependencyInjection/ConfigurationTest.php | 112 ++++++++++++++ .../LlmChainExtensionTest.php | 143 ++++++++++++++++++ 6 files changed, 360 insertions(+) create mode 100644 src/Contract/ContractInterface.php create mode 100644 tests/DependencyInjection/ConfigurationTest.php create mode 100644 tests/DependencyInjection/LlmChainExtensionTest.php diff --git a/README.md b/README.md index f0775e5..f7a9543 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ llm_chain: platform: openai: api_key: '%env(OPENAI_API_KEY)%' + # contract: '@my_custom_contract_service' # Optional: Custom contract service chain: default: model: @@ -31,6 +32,7 @@ llm_chain: platform: anthropic: api_key: '%env(ANTHROPIC_API_KEY)%' + contract: '@my_custom_anthropic_contract' # Optional: Custom contract service azure: # multiple deployments possible gpt_deployment: @@ -38,8 +40,10 @@ llm_chain: deployment: '%env(AZURE_OPENAI_GPT)%' api_key: '%env(AZURE_OPENAI_KEY)%' api_version: '%env(AZURE_GPT_VERSION)%' + contract: '@my_custom_azure_contract' # Optional: Custom contract service google: api_key: '%env(GOOGLE_API_KEY)%' + contract: '@my_custom_google_contract' # Optional: Custom contract service chain: rag: platform: 'llm_chain.platform.azure.gpt_deployment' @@ -86,6 +90,56 @@ llm_chain: version: 'text-embedding-ada-002' ``` +## Custom Contracts + +You can inject custom contract services into your platforms to customize their behavior. This is useful for: +- Custom authentication methods +- Request/response processing +- Platform-specific normalizers and converters + +### Creating a Custom Contract Service + +1. Create a service that implements `PhpLlm\LlmChainBundle\Contract\ContractInterface`: + +```php +children() ->scalarNode('api_key')->isRequired()->end() ->scalarNode('version')->defaultNull()->end() + ->scalarNode('contract')->defaultNull()->end() ->end() ->end() ->arrayNode('azure') @@ -35,27 +36,32 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('base_url')->isRequired()->end() ->scalarNode('deployment')->isRequired()->end() ->scalarNode('api_version')->info('The used API version')->end() + ->scalarNode('contract')->defaultNull()->end() ->end() ->end() ->end() ->arrayNode('google') ->children() ->scalarNode('api_key')->isRequired()->end() + ->scalarNode('contract')->defaultNull()->end() ->end() ->end() ->arrayNode('openai') ->children() ->scalarNode('api_key')->isRequired()->end() + ->scalarNode('contract')->defaultNull()->end() ->end() ->end() ->arrayNode('mistral') ->children() ->scalarNode('api_key')->isRequired()->end() + ->scalarNode('contract')->defaultNull()->end() ->end() ->end() ->arrayNode('openrouter') ->children() ->scalarNode('api_key')->isRequired()->end() + ->scalarNode('contract')->defaultNull()->end() ->end() ->end() ->end() diff --git a/src/DependencyInjection/LlmChainExtension.php b/src/DependencyInjection/LlmChainExtension.php index 03a8678..374ff3b 100644 --- a/src/DependencyInjection/LlmChainExtension.php +++ b/src/DependencyInjection/LlmChainExtension.php @@ -42,6 +42,7 @@ use PhpLlm\LlmChain\Store\Embedder; use PhpLlm\LlmChain\Store\StoreInterface; use PhpLlm\LlmChain\Store\VectorStoreInterface; +use PhpLlm\LlmChainBundle\Contract\ContractInterface; use PhpLlm\LlmChainBundle\Profiler\DataCollector; use PhpLlm\LlmChainBundle\Profiler\TraceablePlatform; use PhpLlm\LlmChainBundle\Profiler\TraceableToolbox; @@ -121,6 +122,8 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('llm_chain.platform.model_client'); $container->registerForAutoconfiguration(ResponseConverterInterface::class) ->addTag('llm_chain.platform.response_converter'); + $container->registerForAutoconfiguration(ContractInterface::class) + ->addTag('llm_chain.platform.contract'); if (false === $container->getParameter('kernel.debug')) { $container->removeDefinition(DataCollector::class); @@ -149,6 +152,10 @@ private function processPlatformConfig(string $type, array $platform, ContainerB $definition->replaceArgument('$version', $platform['version']); } + if (isset($platform['contract']) && null !== $platform['contract']) { + $definition->replaceArgument('$contract', new Reference($platform['contract'])); + } + $container->setDefinition($platformId, $definition); return; @@ -170,6 +177,10 @@ private function processPlatformConfig(string $type, array $platform, ContainerB ]) ->addTag('llm_chain.platform'); + if (isset($config['contract']) && null !== $config['contract']) { + $definition->replaceArgument('$contract', new Reference($config['contract'])); + } + $container->setDefinition($platformId, $definition); } @@ -186,6 +197,10 @@ private function processPlatformConfig(string $type, array $platform, ContainerB ->setArguments(['$apiKey' => $platform['api_key']]) ->addTag('llm_chain.platform'); + if (isset($platform['contract']) && null !== $platform['contract']) { + $definition->replaceArgument('$contract', new Reference($platform['contract'])); + } + $container->setDefinition($platformId, $definition); return; @@ -201,6 +216,10 @@ private function processPlatformConfig(string $type, array $platform, ContainerB ->setArguments(['$apiKey' => $platform['api_key']]) ->addTag('llm_chain.platform'); + if (isset($platform['contract']) && null !== $platform['contract']) { + $definition->replaceArgument('$contract', new Reference($platform['contract'])); + } + $container->setDefinition($platformId, $definition); return; @@ -216,6 +235,10 @@ private function processPlatformConfig(string $type, array $platform, ContainerB ->setArguments(['$apiKey' => $platform['api_key']]) ->addTag('llm_chain.platform'); + if (isset($platform['contract']) && null !== $platform['contract']) { + $definition->replaceArgument('$contract', new Reference($platform['contract'])); + } + $container->setDefinition($platformId, $definition); return; @@ -231,6 +254,10 @@ private function processPlatformConfig(string $type, array $platform, ContainerB ->setArguments(['$apiKey' => $platform['api_key']]) ->addTag('llm_chain.platform'); + if (isset($platform['contract']) && null !== $platform['contract']) { + $definition->replaceArgument('$contract', new Reference($platform['contract'])); + } + $container->setDefinition($platformId, $definition); return; diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..fa802ad --- /dev/null +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,112 @@ +processConfiguration($configuration, [ + 'llm_chain' => [ + 'platform' => [ + 'openai' => [ + 'api_key' => 'test-key', + ], + ], + ], + ]); + + self::assertArrayHasKey('platform', $config); + self::assertArrayHasKey('openai', $config['platform']); + self::assertArrayHasKey('contract', $config['platform']['openai']); + self::assertNull($config['platform']['openai']['contract']); + } + + #[Test] + public function platformContractCanBeConfigured(): void + { + $configuration = new Configuration(); + $processor = new Processor(); + + // Test that contract can be set + $config = $processor->processConfiguration($configuration, [ + 'llm_chain' => [ + 'platform' => [ + 'openai' => [ + 'api_key' => 'test-key', + 'contract' => '@my_custom_contract_service', + ], + ], + ], + ]); + + self::assertArrayHasKey('platform', $config); + self::assertArrayHasKey('openai', $config['platform']); + self::assertArrayHasKey('contract', $config['platform']['openai']); + self::assertSame('@my_custom_contract_service', $config['platform']['openai']['contract']); + } + + #[Test] + public function allPlatformTypesAcceptContractConfiguration(): void + { + $configuration = new Configuration(); + $processor = new Processor(); + + $config = $processor->processConfiguration($configuration, [ + 'llm_chain' => [ + 'platform' => [ + 'anthropic' => [ + 'api_key' => 'test-key', + 'contract' => '@anthropic_contract', + ], + 'google' => [ + 'api_key' => 'test-key', + 'contract' => '@google_contract', + ], + 'openai' => [ + 'api_key' => 'test-key', + 'contract' => '@openai_contract', + ], + 'mistral' => [ + 'api_key' => 'test-key', + 'contract' => '@mistral_contract', + ], + 'openrouter' => [ + 'api_key' => 'test-key', + 'contract' => '@openrouter_contract', + ], + 'azure' => [ + 'gpt_deployment' => [ + 'api_key' => 'test-key', + 'base_url' => 'https://test.openai.azure.com', + 'deployment' => 'gpt-4', + 'api_version' => '2023-03-15-preview', + 'contract' => '@azure_contract', + ], + ], + ], + ], + ]); + + // Check all platforms have the contract key configured + self::assertSame('@anthropic_contract', $config['platform']['anthropic']['contract']); + self::assertSame('@google_contract', $config['platform']['google']['contract']); + self::assertSame('@openai_contract', $config['platform']['openai']['contract']); + self::assertSame('@mistral_contract', $config['platform']['mistral']['contract']); + self::assertSame('@openrouter_contract', $config['platform']['openrouter']['contract']); + self::assertSame('@azure_contract', $config['platform']['azure']['gpt_deployment']['contract']); + } +} \ No newline at end of file diff --git a/tests/DependencyInjection/LlmChainExtensionTest.php b/tests/DependencyInjection/LlmChainExtensionTest.php new file mode 100644 index 0000000..c3c9506 --- /dev/null +++ b/tests/DependencyInjection/LlmChainExtensionTest.php @@ -0,0 +1,143 @@ +setParameter('kernel.debug', false); + $extension = new LlmChainExtension(); + + $extension->load([], $container); + + $autoconfiguredInterfaces = $container->getAutoconfiguredInstanceof(); + self::assertArrayHasKey(ContractInterface::class, $autoconfiguredInterfaces); + + $contractConfig = $autoconfiguredInterfaces[ContractInterface::class]; + $tags = $contractConfig->getTags(); + self::assertArrayHasKey('llm_chain.platform.contract', $tags); + } + + #[Test] + public function platformWithoutContractWorksAsNormal(): void + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $extension = new LlmChainExtension(); + + $config = [ + 'llm_chain' => [ + 'platform' => [ + 'openai' => [ + 'api_key' => 'test-key', + ], + ], + ], + ]; + + $extension->load([$config['llm_chain']], $container); + + // Platform should be registered + self::assertTrue($container->hasDefinition('llm_chain.platform.openai')); + + $definition = $container->getDefinition('llm_chain.platform.openai'); + $arguments = $definition->getArguments(); + + // Should have the API key argument + self::assertArrayHasKey('$apiKey', $arguments); + self::assertSame('test-key', $arguments['$apiKey']); + } + + #[Test] + public function platformWithContractInjectsContractService(): void + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + + // Register a mock contract service + $container->register('my_custom_contract', ContractInterface::class); + + $extension = new LlmChainExtension(); + + $config = [ + 'llm_chain' => [ + 'platform' => [ + 'openai' => [ + 'api_key' => 'test-key', + 'contract' => 'my_custom_contract', + ], + ], + ], + ]; + + $extension->load([$config['llm_chain']], $container); + + // Platform should be registered + self::assertTrue($container->hasDefinition('llm_chain.platform.openai')); + + $definition = $container->getDefinition('llm_chain.platform.openai'); + $arguments = $definition->getArguments(); + + // Should have the API key argument + self::assertArrayHasKey('$apiKey', $arguments); + self::assertSame('test-key', $arguments['$apiKey']); + + // Should have the contract argument as a service reference + self::assertArrayHasKey('$contract', $arguments); + self::assertInstanceOf(Reference::class, $arguments['$contract']); + self::assertSame('my_custom_contract', (string) $arguments['$contract']); + } + + #[Test] + public function azurePlatformWithContractInjectsContractService(): void + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + + // Register a mock contract service + $container->register('my_azure_contract', ContractInterface::class); + + $extension = new LlmChainExtension(); + + $config = [ + 'llm_chain' => [ + 'platform' => [ + 'azure' => [ + 'gpt_deployment' => [ + 'api_key' => 'test-key', + 'base_url' => 'https://test.openai.azure.com', + 'deployment' => 'gpt-4', + 'api_version' => '2023-03-15-preview', + 'contract' => 'my_azure_contract', + ], + ], + ], + ], + ]; + + $extension->load([$config['llm_chain']], $container); + + // Platform should be registered + self::assertTrue($container->hasDefinition('llm_chain.platform.azure.gpt_deployment')); + + $definition = $container->getDefinition('llm_chain.platform.azure.gpt_deployment'); + $arguments = $definition->getArguments(); + + // Should have the contract argument as a service reference + self::assertArrayHasKey('$contract', $arguments); + self::assertInstanceOf(Reference::class, $arguments['$contract']); + self::assertSame('my_azure_contract', (string) $arguments['$contract']); + } +} \ No newline at end of file From 1cac3f9e5244a9b567ec8b2e247ab2471dc80d6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Jun 2025 17:08:40 +0000 Subject: [PATCH 3/3] Add comprehensive tests and example for contract service injection Co-authored-by: chr-hertel <2852185+chr-hertel@users.noreply.github.com> --- tests/Contract/ExampleCustomContract.php | 37 ++++ tests/Integration/ContractIntegrationTest.php | 169 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 tests/Contract/ExampleCustomContract.php create mode 100644 tests/Integration/ContractIntegrationTest.php diff --git a/tests/Contract/ExampleCustomContract.php b/tests/Contract/ExampleCustomContract.php new file mode 100644 index 0000000..6742313 --- /dev/null +++ b/tests/Contract/ExampleCustomContract.php @@ -0,0 +1,37 @@ +customSetting; + } + + /** + * Example method that could be used for custom contract logic. + * In a real implementation, this might handle custom normalizers, + * authentication, or request/response processing. + */ + public function processCustomLogic(array $data): array + { + // Custom processing logic would go here + return array_merge($data, ['processed_by' => self::class]); + } +} \ No newline at end of file diff --git a/tests/Integration/ContractIntegrationTest.php b/tests/Integration/ContractIntegrationTest.php new file mode 100644 index 0000000..3af83f5 --- /dev/null +++ b/tests/Integration/ContractIntegrationTest.php @@ -0,0 +1,169 @@ + [ + 'platform' => [ + 'openai' => [ + 'api_key' => 'sk-test123', + 'contract' => 'my_openai_contract', + ], + 'anthropic' => [ + 'api_key' => 'claude-test456', + 'version' => '2023-06-01', + 'contract' => 'my_anthropic_contract', + ], + 'azure' => [ + 'main' => [ + 'api_key' => 'azure-test789', + 'base_url' => 'https://test.openai.azure.com', + 'deployment' => 'gpt-4', + 'api_version' => '2023-03-15-preview', + 'contract' => 'my_azure_contract', + ], + ], + 'google' => [ + 'api_key' => 'google-test', + // No contract specified - should default to null + ], + ], + ], + ]; + + $processedConfig = $processor->processConfiguration($configuration, [$yamlConfig['llm_chain']]); + + // Verify configuration processing + self::assertSame('my_openai_contract', $processedConfig['platform']['openai']['contract']); + self::assertSame('my_anthropic_contract', $processedConfig['platform']['anthropic']['contract']); + self::assertSame('my_azure_contract', $processedConfig['platform']['azure']['main']['contract']); + self::assertNull($processedConfig['platform']['google']['contract']); + + // 2. Test Extension Processing + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + + // Register mock contract services + $container->register('my_openai_contract', ContractInterface::class); + $container->register('my_anthropic_contract', ContractInterface::class); + $container->register('my_azure_contract', ContractInterface::class); + + $extension = new LlmChainExtension(); + $extension->load([$processedConfig], $container); + + // 3. Verify Platform Service Definitions + + // OpenAI platform with contract + self::assertTrue($container->hasDefinition('llm_chain.platform.openai')); + $openaiDef = $container->getDefinition('llm_chain.platform.openai'); + $openaiArgs = $openaiDef->getArguments(); + self::assertArrayHasKey('$apiKey', $openaiArgs); + self::assertSame('sk-test123', $openaiArgs['$apiKey']); + self::assertArrayHasKey('$contract', $openaiArgs); + self::assertInstanceOf(Reference::class, $openaiArgs['$contract']); + self::assertSame('my_openai_contract', (string) $openaiArgs['$contract']); + + // Anthropic platform with contract and version + self::assertTrue($container->hasDefinition('llm_chain.platform.anthropic')); + $anthropicDef = $container->getDefinition('llm_chain.platform.anthropic'); + $anthropicArgs = $anthropicDef->getArguments(); + self::assertArrayHasKey('$apiKey', $anthropicArgs); + self::assertSame('claude-test456', $anthropicArgs['$apiKey']); + self::assertArrayHasKey('$version', $anthropicArgs); + self::assertSame('2023-06-01', $anthropicArgs['$version']); + self::assertArrayHasKey('$contract', $anthropicArgs); + self::assertInstanceOf(Reference::class, $anthropicArgs['$contract']); + self::assertSame('my_anthropic_contract', (string) $anthropicArgs['$contract']); + + // Azure platform with contract + self::assertTrue($container->hasDefinition('llm_chain.platform.azure.main')); + $azureDef = $container->getDefinition('llm_chain.platform.azure.main'); + $azureArgs = $azureDef->getArguments(); + self::assertArrayHasKey('$baseUrl', $azureArgs); + self::assertArrayHasKey('$deployment', $azureArgs); + self::assertArrayHasKey('$apiVersion', $azureArgs); + self::assertArrayHasKey('$apiKey', $azureArgs); + self::assertArrayHasKey('$contract', $azureArgs); + self::assertInstanceOf(Reference::class, $azureArgs['$contract']); + self::assertSame('my_azure_contract', (string) $azureArgs['$contract']); + + // Google platform without contract + self::assertTrue($container->hasDefinition('llm_chain.platform.google')); + $googleDef = $container->getDefinition('llm_chain.platform.google'); + $googleArgs = $googleDef->getArguments(); + self::assertArrayHasKey('$apiKey', $googleArgs); + self::assertSame('google-test', $googleArgs['$apiKey']); + // Should not have contract argument when null + self::assertArrayNotHasKey('$contract', $googleArgs); + + // 4. Verify ContractInterface Autoconfiguration + $autoconfigured = $container->getAutoconfiguredInstanceof(); + self::assertArrayHasKey(ContractInterface::class, $autoconfigured); + $contractConfig = $autoconfigured[ContractInterface::class]; + $tags = $contractConfig->getTags(); + self::assertArrayHasKey('llm_chain.platform.contract', $tags); + } + + #[Test] + public function backwardCompatibilityWithoutContracts(): void + { + // Test that existing configurations without contracts continue to work + $configuration = new Configuration(); + $processor = new Processor(); + + $yamlConfig = [ + 'llm_chain' => [ + 'platform' => [ + 'openai' => [ + 'api_key' => 'sk-test123', + // No contract specified + ], + ], + ], + ]; + + $processedConfig = $processor->processConfiguration($configuration, [$yamlConfig['llm_chain']]); + + // Contract should default to null + self::assertNull($processedConfig['platform']['openai']['contract']); + + // Extension should work without contract injection + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + + $extension = new LlmChainExtension(); + $extension->load([$processedConfig], $container); + + self::assertTrue($container->hasDefinition('llm_chain.platform.openai')); + $definition = $container->getDefinition('llm_chain.platform.openai'); + $arguments = $definition->getArguments(); + + // Should have API key but no contract argument + self::assertArrayHasKey('$apiKey', $arguments); + self::assertSame('sk-test123', $arguments['$apiKey']); + self::assertArrayNotHasKey('$contract', $arguments); + } +} \ No newline at end of file