Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/llm-usage-book.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
19 changes: 18 additions & 1 deletion prefabs.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,32 @@
# 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"
# github_access_key: "ghp_xxxxxxxxxxxx"
# 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
33 changes: 33 additions & 0 deletions src/Prefab/Domain/Service/PrefabLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,46 @@ 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,
$githubAccessKey,
$llmModelProvider->value,
$llmApiKey,
$keysVisible,
$photoBuilderProvider,
$photoBuilderApiKeyNormalized,
);
}
}
14 changes: 8 additions & 6 deletions src/Prefab/Facade/Dto/PrefabDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
}
8 changes: 6 additions & 2 deletions src/ProjectMgmt/Facade/ProjectMgmtFacade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand Down
119 changes: 119 additions & 0 deletions tests/Unit/Prefab/PrefabLoaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down