diff --git a/docs/llm-usage-book.md b/docs/llm-usage-book.md index 4513e8ae..a0b0f60d 100644 --- a/docs/llm-usage-book.md +++ b/docs/llm-usage-book.md @@ -79,7 +79,7 @@ These fields are required when creating or editing a project. - `photoBuilderLlmModelProvider` -- `openai` or `google`, nullable - `photoBuilderLlmModelProviderApiKey` -- the matching API key, nullable -**Fallback rule**: When the PhotoBuilder fields are `null`, the application uses the content editing provider and API key for the PhotoBuilder. This is the default for all projects, including prefab-created projects. +**Fallback rule**: When the PhotoBuilder fields are `null`, the application uses the content editing provider and API key for the PhotoBuilder. This is the default for all projects, including prefab-created projects. Prefabs can optionally set dedicated PhotoBuilder provider and API key in `prefabs.yaml` (e.g. Google Gemini for image generation) so that the created project uses them instead of the content-editing settings. The project form offers two options: diff --git a/prefabs.yaml.example b/prefabs.yaml.example index e155d366..7ffb2443 100644 --- a/prefabs.yaml.example +++ b/prefabs.yaml.example @@ -8,11 +8,18 @@ # project_link: Git repository URL (e.g. https://github.com/org/repo.git) # github_access_key: GitHub personal access token # llm_model_provider: One of: openai (see LlmModelProvider enum) -# llm_api_key: API key for the LLM provider +# llm_api_key: API key for the LLM provider (content editing) # keys_visible: (optional, default true) If false, GitHub and LLM keys are # set and used by the app but never shown to org users # (edit form, reuse-existing-key UI). # +# Optional keys for dedicated PhotoBuilder (e.g. Google Gemini image generation): +# photo_builder_llm_model_provider: One of: openai, google. If set, photo_builder_llm_api_key is required. +# photo_builder_llm_api_key: API key for the PhotoBuilder provider (image + prompt generation). +# If both are omitted, PhotoBuilder uses the content-editing LLM settings. +# When set, the created project will use this provider and key for image generation +# (e.g. Gemini models). Do not commit real keys; use deployment secrets (CI, gh-secrets, etc.). +# # prefabs: # - name: "My Prefab Project" # project_link: "https://github.com/example/repo.git" @@ -20,3 +27,13 @@ # llm_model_provider: "openai" # llm_api_key: "sk-..." # keys_visible: false +# +# # Example: prefab with dedicated Google Gemini for image generation +# - name: "Prefab with Gemini PhotoBuilder" +# project_link: "https://github.com/example/other-repo.git" +# github_access_key: "ghp_xxxxxxxxxxxx" +# llm_model_provider: "openai" +# llm_api_key: "sk-..." +# photo_builder_llm_model_provider: "google" +# photo_builder_llm_api_key: "AIza..." +# keys_visible: false diff --git a/src/Prefab/Domain/Service/PrefabLoader.php b/src/Prefab/Domain/Service/PrefabLoader.php index 05f3ff42..ff28e6fa 100644 --- a/src/Prefab/Domain/Service/PrefabLoader.php +++ b/src/Prefab/Domain/Service/PrefabLoader.php @@ -108,6 +108,37 @@ private function parseEntry(array $item, int $index): ?PrefabDto return null; } + $photoBuilderProviderRaw = array_key_exists('photo_builder_llm_model_provider', $item) && is_string($item['photo_builder_llm_model_provider']) + ? trim($item['photo_builder_llm_model_provider']) + : null; + $photoBuilderApiKey = array_key_exists('photo_builder_llm_api_key', $item) && is_string($item['photo_builder_llm_api_key']) + ? $item['photo_builder_llm_api_key'] + : null; + + if (($photoBuilderProviderRaw !== null && $photoBuilderProviderRaw !== '') !== ($photoBuilderApiKey !== null && $photoBuilderApiKey !== '')) { + $this->logger->warning('Prefab entry skipped: photo_builder_llm_model_provider and photo_builder_llm_api_key must both be set or both omitted', [ + 'index' => $index, + ]); + + return null; + } + + $photoBuilderProvider = null; + $photoBuilderApiKeyNormalized = null; + if ($photoBuilderProviderRaw !== null && $photoBuilderProviderRaw !== '' && $photoBuilderApiKey !== null && $photoBuilderApiKey !== '') { + $photoBuilderProviderEnum = LlmModelProvider::tryFrom($photoBuilderProviderRaw); + if ($photoBuilderProviderEnum === null || !$photoBuilderProviderEnum->supportsPhotoBuilder()) { + $this->logger->warning('Prefab entry skipped: invalid or unsupported photo_builder_llm_model_provider', [ + 'index' => $index, + 'value' => $photoBuilderProviderRaw, + ]); + + return null; + } + $photoBuilderProvider = $photoBuilderProviderEnum->value; + $photoBuilderApiKeyNormalized = $photoBuilderApiKey; + } + return new PrefabDto( $name, $projectLink, @@ -115,6 +146,8 @@ private function parseEntry(array $item, int $index): ?PrefabDto $llmModelProvider->value, $llmApiKey, $keysVisible, + $photoBuilderProvider, + $photoBuilderApiKeyNormalized, ); } } diff --git a/src/Prefab/Facade/Dto/PrefabDto.php b/src/Prefab/Facade/Dto/PrefabDto.php index edf997ef..36812168 100644 --- a/src/Prefab/Facade/Dto/PrefabDto.php +++ b/src/Prefab/Facade/Dto/PrefabDto.php @@ -11,12 +11,14 @@ final readonly class PrefabDto { public function __construct( - public string $name, - public string $projectLink, - public string $githubAccessKey, - public string $contentEditingLlmModelProvider, - public string $contentEditingLlmApiKey, - public bool $keysVisible = true, + public string $name, + public string $projectLink, + public string $githubAccessKey, + public string $contentEditingLlmModelProvider, + public string $contentEditingLlmApiKey, + public bool $keysVisible = true, + public ?string $photoBuilderLlmModelProvider = null, + public ?string $photoBuilderLlmApiKey = null, ) { } } diff --git a/src/ProjectMgmt/Facade/ProjectMgmtFacade.php b/src/ProjectMgmt/Facade/ProjectMgmtFacade.php index da8051fe..bdde2071 100644 --- a/src/ProjectMgmt/Facade/ProjectMgmtFacade.php +++ b/src/ProjectMgmt/Facade/ProjectMgmtFacade.php @@ -40,6 +40,10 @@ public function createProjectFromPrefab(string $organizationId, PrefabDto $prefa throw new RuntimeException('Invalid prefab content_editing_llm_model_provider: ' . $prefab->contentEditingLlmModelProvider); } + $photoBuilderProvider = $prefab->photoBuilderLlmModelProvider !== null + ? LlmModelProvider::tryFrom($prefab->photoBuilderLlmModelProvider) + : null; + $project = $this->projectService->create( $organizationId, $prefab->name, @@ -60,8 +64,8 @@ public function createProjectFromPrefab(string $organizationId, PrefabDto $prefa null, null, $prefab->keysVisible, - null, // photoBuilderLlmModelProvider: prefabs always use content editing settings - null, // photoBuilderLlmModelProviderApiKey + $photoBuilderProvider, + $prefab->photoBuilderLlmApiKey, ); $projectId = $project->getId(); diff --git a/tests/Unit/Prefab/PrefabLoaderTest.php b/tests/Unit/Prefab/PrefabLoaderTest.php index f8065e19..f257923a 100644 --- a/tests/Unit/Prefab/PrefabLoaderTest.php +++ b/tests/Unit/Prefab/PrefabLoaderTest.php @@ -54,6 +54,125 @@ public function testLoadReturnsPrefabsWhenValidYaml(): void self::assertSame('openai', $result[0]->contentEditingLlmModelProvider); self::assertSame('sk-test', $result[0]->contentEditingLlmApiKey); self::assertFalse($result[0]->keysVisible); + self::assertNull($result[0]->photoBuilderLlmModelProvider); + self::assertNull($result[0]->photoBuilderLlmApiKey); + } finally { + if (is_file($yamlPath)) { + unlink($yamlPath); + } + if (is_dir($tmpDir)) { + rmdir($tmpDir); + } + } + } + + public function testLoadPrefabWithDedicatedPhotoBuilderKeys(): void + { + $tmpDir = sys_get_temp_dir() . '/prefab_loader_test_' . uniqid(); + mkdir($tmpDir, 0755, true); + $yamlPath = $tmpDir . '/prefabs.yaml'; + $yaml = <<<'YAML' +prefabs: + - name: "Prefab with Gemini" + project_link: "https://github.com/example/repo.git" + github_access_key: "ghp_xxx" + llm_model_provider: "openai" + llm_api_key: "sk-xxx" + photo_builder_llm_model_provider: "google" + photo_builder_llm_api_key: "AIza_xxx" + keys_visible: false +YAML; + file_put_contents($yamlPath, $yaml); + + try { + $parameterBag = $this->createMock(ParameterBagInterface::class); + $parameterBag->method('get')->with('kernel.project_dir')->willReturn($tmpDir); + $logger = $this->createMock(LoggerInterface::class); + + $loader = new PrefabLoader($parameterBag, $logger); + $result = $loader->load(); + + self::assertCount(1, $result); + self::assertSame('Prefab with Gemini', $result[0]->name); + self::assertSame('google', $result[0]->photoBuilderLlmModelProvider); + self::assertSame('AIza_xxx', $result[0]->photoBuilderLlmApiKey); + } finally { + if (is_file($yamlPath)) { + unlink($yamlPath); + } + if (is_dir($tmpDir)) { + rmdir($tmpDir); + } + } + } + + public function testLoadSkipsEntryWhenOnlyPhotoBuilderProviderSet(): void + { + $tmpDir = sys_get_temp_dir() . '/prefab_loader_test_' . uniqid(); + mkdir($tmpDir, 0755, true); + $yamlPath = $tmpDir . '/prefabs.yaml'; + $yaml = <<<'YAML' +prefabs: + - name: "Valid One" + project_link: "https://github.com/a/repo.git" + github_access_key: "ghp_a" + llm_model_provider: "openai" + llm_api_key: "sk-a" + - name: "Invalid photo_builder only provider" + project_link: "https://github.com/b/repo.git" + github_access_key: "ghp_b" + llm_model_provider: "openai" + llm_api_key: "sk-b" + photo_builder_llm_model_provider: "google" +YAML; + file_put_contents($yamlPath, $yaml); + + try { + $parameterBag = $this->createMock(ParameterBagInterface::class); + $parameterBag->method('get')->with('kernel.project_dir')->willReturn($tmpDir); + $logger = $this->createMock(LoggerInterface::class); + + $loader = new PrefabLoader($parameterBag, $logger); + $result = $loader->load(); + + self::assertCount(1, $result); + self::assertSame('Valid One', $result[0]->name); + } finally { + if (is_file($yamlPath)) { + unlink($yamlPath); + } + if (is_dir($tmpDir)) { + rmdir($tmpDir); + } + } + } + + public function testLoadSkipsEntryWhenInvalidPhotoBuilderProvider(): void + { + $tmpDir = sys_get_temp_dir() . '/prefab_loader_test_' . uniqid(); + mkdir($tmpDir, 0755, true); + $yamlPath = $tmpDir . '/prefabs.yaml'; + $yaml = <<<'YAML' +prefabs: + - name: "Bad provider" + project_link: "https://github.com/c/repo.git" + github_access_key: "ghp_c" + llm_model_provider: "openai" + llm_api_key: "sk-c" + photo_builder_llm_model_provider: "anthropic" + photo_builder_llm_api_key: "key" +YAML; + file_put_contents($yamlPath, $yaml); + + try { + $parameterBag = $this->createMock(ParameterBagInterface::class); + $parameterBag->method('get')->with('kernel.project_dir')->willReturn($tmpDir); + $logger = $this->createMock(LoggerInterface::class); + + $loader = new PrefabLoader($parameterBag, $logger); + $result = $loader->load(); + + self::assertCount(0, $result); } finally { if (is_file($yamlPath)) { unlink($yamlPath);