fastango 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. fastango/__init__.py +3 -0
  2. fastango/cli.py +439 -0
  3. fastango/generator/__init__.py +1 -0
  4. fastango/generator/constraints.py +77 -0
  5. fastango/generator/model_catalog.py +180 -0
  6. fastango/generator/models.py +67 -0
  7. fastango/generator/planner.py +126 -0
  8. fastango/generator/preview.py +88 -0
  9. fastango/generator/providers/__init__.py +1 -0
  10. fastango/generator/providers/anthropic.py +53 -0
  11. fastango/generator/providers/base.py +48 -0
  12. fastango/generator/providers/openai.py +44 -0
  13. fastango/generator/rules.py +162 -0
  14. fastango/generator/skills.py +104 -0
  15. fastango/integrations/__init__.py +5 -0
  16. fastango/integrations/base.py +27 -0
  17. fastango/integrations/builtins.py +434 -0
  18. fastango/integrations/catalog.py +224 -0
  19. fastango/integrations/categories/__init__.py +1 -0
  20. fastango/integrations/categories/ai.py +94 -0
  21. fastango/integrations/categories/api.py +47 -0
  22. fastango/integrations/categories/auth.py +119 -0
  23. fastango/integrations/categories/cache_queue.py +65 -0
  24. fastango/integrations/categories/database.py +100 -0
  25. fastango/integrations/categories/deploy.py +117 -0
  26. fastango/integrations/categories/devtools.py +59 -0
  27. fastango/integrations/categories/observability.py +56 -0
  28. fastango/integrations/categories/payments.py +98 -0
  29. fastango/integrations/categories/product.py +195 -0
  30. fastango/integrations/categories/storage.py +59 -0
  31. fastango/py.typed +0 -0
  32. fastango/scaffold/__init__.py +6 -0
  33. fastango/scaffold/config.py +111 -0
  34. fastango/scaffold/engine.py +61 -0
  35. fastango/scaffold/filesystem.py +54 -0
  36. fastango/scaffold/plan.py +111 -0
  37. fastango/scaffold/preview.py +34 -0
  38. fastango/scaffold/prompts.py +67 -0
  39. fastango/scaffold/registry.py +116 -0
  40. fastango/scaffold/renderer.py +98 -0
  41. fastango/templates/__init__.py +1 -0
  42. fastango/templates/project/__init__.py +1 -0
  43. fastango/templates/project/mvc/.env.example.j2 +3 -0
  44. fastango/templates/project/mvc/.gitignore.j2 +8 -0
  45. fastango/templates/project/mvc/README.md.j2 +33 -0
  46. fastango/templates/project/mvc/app/__init__.py.j2 +1 -0
  47. fastango/templates/project/mvc/app/api/__init__.py.j2 +1 -0
  48. fastango/templates/project/mvc/app/api/deps.py.j2 +5 -0
  49. fastango/templates/project/mvc/app/api/routes/__init__.py.j2 +1 -0
  50. fastango/templates/project/mvc/app/api/routes/health.py.j2 +18 -0
  51. fastango/templates/project/mvc/app/core/__init__.py.j2 +1 -0
  52. fastango/templates/project/mvc/app/core/config.py.j2 +34 -0
  53. fastango/templates/project/mvc/app/core/logging.py.j2 +10 -0
  54. fastango/templates/project/mvc/app/main.py.j2 +46 -0
  55. fastango/templates/project/mvc/app/models/__init__.py.j2 +1 -0
  56. fastango/templates/project/mvc/app/repositories/__init__.py.j2 +1 -0
  57. fastango/templates/project/mvc/app/schemas/__init__.py.j2 +1 -0
  58. fastango/templates/project/mvc/app/schemas/health.py.j2 +8 -0
  59. fastango/templates/project/mvc/app/services/__init__.py.j2 +1 -0
  60. fastango/templates/project/mvc/llms.txt.j2 +33 -0
  61. fastango/templates/project/mvc/pyproject.toml.j2 +30 -0
  62. fastango/templates/project/mvc/tests/test_health.py.j2 +12 -0
  63. fastango/templates/project/simple/.env.example.j2 +3 -0
  64. fastango/templates/project/simple/.gitignore.j2 +8 -0
  65. fastango/templates/project/simple/README.md.j2 +29 -0
  66. fastango/templates/project/simple/app/__init__.py.j2 +1 -0
  67. fastango/templates/project/simple/app/main.py.j2 +45 -0
  68. fastango/templates/project/simple/app/routes.py.j2 +18 -0
  69. fastango/templates/project/simple/app/schemas.py.j2 +8 -0
  70. fastango/templates/project/simple/app/settings.py.j2 +34 -0
  71. fastango/templates/project/simple/llms.txt.j2 +30 -0
  72. fastango/templates/project/simple/pyproject.toml.j2 +30 -0
  73. fastango/templates/project/simple/tests/test_health.py.j2 +12 -0
  74. fastango/terminal/__init__.py +1 -0
  75. fastango/terminal/models.py +27 -0
  76. fastango/terminal/tables.py +51 -0
  77. fastango/terminal/theme.py +36 -0
  78. fastango/tui/__init__.py +1 -0
  79. fastango/tui/app.py +169 -0
  80. fastango-0.1.0.dist-info/METADATA +260 -0
  81. fastango-0.1.0.dist-info/RECORD +84 -0
  82. fastango-0.1.0.dist-info/WHEEL +4 -0
  83. fastango-0.1.0.dist-info/entry_points.txt +2 -0
  84. fastango-0.1.0.dist-info/licenses/LICENSE +21 -0
fastango/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Fastango: a uv-first FastAPI project generator."""
2
+
3
+ __version__ = "0.1.0"
fastango/cli.py ADDED
@@ -0,0 +1,439 @@
1
+ """Command-line interface for Fastango."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from rich.prompt import Confirm
11
+
12
+ from fastango import __version__
13
+ from fastango.generator.constraints import GenerationConstraintError
14
+ from fastango.generator.model_catalog import ModelProvider, available_models
15
+ from fastango.generator.models import GenerationProvider, GenerationRequest
16
+ from fastango.generator.planner import build_generation_plan
17
+ from fastango.generator.preview import (
18
+ generation_preview,
19
+ plan_to_json,
20
+ reasons_table,
21
+ security_table,
22
+ )
23
+ from fastango.generator.providers.base import ProviderError
24
+ from fastango.scaffold.config import ProjectConfig
25
+ from fastango.scaffold.engine import ScaffoldEngine
26
+ from fastango.scaffold.filesystem import ScaffoldWriteError
27
+ from fastango.scaffold.prompts import prompt_for_config
28
+ from fastango.scaffold.registry import IntegrationError, IntegrationRegistry
29
+ from fastango.terminal.models import models_table
30
+ from fastango.terminal.tables import integrations_table, presets_table
31
+ from fastango.terminal.theme import make_console
32
+ from fastango.tui.app import run_playground
33
+
34
+ app = typer.Typer(
35
+ help="Generate FastAPI projects with uv-first templates.",
36
+ invoke_without_command=True,
37
+ no_args_is_help=False,
38
+ )
39
+ console = make_console()
40
+
41
+
42
+ def version_callback(value: bool) -> None:
43
+ if value:
44
+ console.print(f"fastango {__version__}")
45
+ raise typer.Exit
46
+
47
+
48
+ @app.callback()
49
+ def main(
50
+ ctx: typer.Context,
51
+ version: Annotated[
52
+ bool | None,
53
+ typer.Option("--version", callback=version_callback, help="Show Fastango version."),
54
+ ] = None,
55
+ ) -> None:
56
+ """Fastango CLI root command."""
57
+
58
+ if ctx.invoked_subcommand is None:
59
+ launch_playground(output_dir=Path.cwd(), dry_run=False, force=False)
60
+
61
+
62
+ def launch_playground(*, output_dir: Path, dry_run: bool, force: bool) -> None:
63
+ registry = IntegrationRegistry.builtins()
64
+ config = run_playground(registry=registry, output_dir=output_dir, force=force)
65
+ if config is None:
66
+ console.print("[fastango.muted]Cancelled.[/]")
67
+ raise typer.Exit
68
+ result = ScaffoldEngine(registry=registry).create(config, dry_run=dry_run)
69
+ if dry_run:
70
+ console.print(
71
+ f"[fastango.success]Dry run complete.[/] {len(result.files)} files would be generated:"
72
+ )
73
+ for path in result.files:
74
+ console.print(f" {path.relative_to(result.target_dir)}")
75
+ return
76
+ console.print(f"[fastango.success]Created FastAPI project at {result.target_dir}[/]")
77
+ console.print("\nNext steps:")
78
+ console.print(f" cd {result.target_dir}")
79
+ console.print(" uv sync")
80
+ console.print(" cp .env.example .env")
81
+ console.print(" uv run fastapi dev app/main.py")
82
+
83
+
84
+ @app.command("integrations")
85
+ def integrations(
86
+ category: Annotated[
87
+ str | None,
88
+ typer.Option("--category", "-c", help="Filter integrations by category."),
89
+ ] = None,
90
+ search: Annotated[
91
+ str | None,
92
+ typer.Option("--search", "-s", help="Search integrations by name, tag, or description."),
93
+ ] = None,
94
+ show_presets: Annotated[
95
+ bool,
96
+ typer.Option("--presets", help="Show curated presets instead of integrations."),
97
+ ] = False,
98
+ json_output: Annotated[
99
+ bool,
100
+ typer.Option("--json", help="Print machine-readable JSON."),
101
+ ] = False,
102
+ ) -> None:
103
+ """List built-in integrations."""
104
+
105
+ registry = IntegrationRegistry.builtins()
106
+ if show_presets:
107
+ presets = registry.presets()
108
+ if json_output:
109
+ console.print_json(
110
+ json.dumps(
111
+ [
112
+ {
113
+ "name": preset.name,
114
+ "label": preset.label,
115
+ "description": preset.description,
116
+ "integrations": preset.integrations,
117
+ "tags": preset.tags,
118
+ }
119
+ for preset in presets
120
+ ]
121
+ )
122
+ )
123
+ return
124
+ console.print(presets_table(presets))
125
+ return
126
+
127
+ filtered = registry.list(category=category, search=search)
128
+ if json_output:
129
+ console.print_json(
130
+ json.dumps(
131
+ [
132
+ {
133
+ "name": integration.name,
134
+ "label": integration.label,
135
+ "category": integration.category,
136
+ "description": integration.description,
137
+ "tags": integration.tags,
138
+ "supports": integration.supports,
139
+ "requires": integration.requires,
140
+ "conflicts": integration.conflicts,
141
+ "aliases": integration.aliases,
142
+ "maturity": integration.maturity,
143
+ }
144
+ for integration in filtered
145
+ ]
146
+ )
147
+ )
148
+ return
149
+ console.print(integrations_table(filtered))
150
+
151
+
152
+ @app.command()
153
+ def playground(
154
+ output_dir: Annotated[
155
+ Path | None,
156
+ typer.Option("--output-dir", "-o", help="Parent directory for the generated project."),
157
+ ] = None,
158
+ dry_run: Annotated[
159
+ bool,
160
+ typer.Option("--dry-run", help="Preview the selected project without writing files."),
161
+ ] = False,
162
+ force: Annotated[
163
+ bool,
164
+ typer.Option("--force", help="Overwrite files if they already exist."),
165
+ ] = False,
166
+ ) -> None:
167
+ """Launch the branded interactive Fastango playground."""
168
+
169
+ launch_playground(output_dir=output_dir or Path.cwd(), dry_run=dry_run, force=force)
170
+
171
+
172
+ @app.command("models")
173
+ def models(
174
+ provider: Annotated[
175
+ ModelProvider | None,
176
+ typer.Option("--provider", help="Filter models by provider: anthropic or openai."),
177
+ ] = None,
178
+ json_output: Annotated[
179
+ bool,
180
+ typer.Option("--json", help="Print supported models as JSON."),
181
+ ] = False,
182
+ static: Annotated[
183
+ bool,
184
+ typer.Option("--static", help="Show Fastango's curated offline model list."),
185
+ ] = False,
186
+ ) -> None:
187
+ """List AI generation models from API keys, with curated fallback."""
188
+
189
+ supported = available_models(provider, live=not static)
190
+ if json_output:
191
+ console.print_json(
192
+ json.dumps(
193
+ [
194
+ {
195
+ "provider": model.provider,
196
+ "model": model.model,
197
+ "label": model.label,
198
+ "description": model.description,
199
+ "default": model.default,
200
+ "source": model.source,
201
+ }
202
+ for model in supported
203
+ ]
204
+ )
205
+ )
206
+ return
207
+ console.print(models_table(supported))
208
+
209
+
210
+ @app.command()
211
+ def generate(
212
+ prompt: Annotated[
213
+ str,
214
+ typer.Argument(help="Natural-language FastAPI project idea."),
215
+ ],
216
+ provider: Annotated[
217
+ GenerationProvider,
218
+ typer.Option(
219
+ "--provider",
220
+ help="Generation provider: deterministic, anthropic, openai, or auto.",
221
+ ),
222
+ ] = "deterministic",
223
+ model: Annotated[
224
+ str | None,
225
+ typer.Option("--model", help="Supported model ID for Anthropic/OpenAI providers."),
226
+ ] = None,
227
+ style: Annotated[
228
+ str | None,
229
+ typer.Option("--style", help="Template style: simple or mvc."),
230
+ ] = None,
231
+ project_name: Annotated[
232
+ str | None,
233
+ typer.Option("--project-name", help="Project name to use instead of inferring one."),
234
+ ] = None,
235
+ output_dir: Annotated[
236
+ Path | None,
237
+ typer.Option("--output-dir", "-o", help="Parent directory for the generated project."),
238
+ ] = None,
239
+ yes: Annotated[
240
+ bool,
241
+ typer.Option("--yes", "-y", help="Generate without an interactive confirmation."),
242
+ ] = False,
243
+ dry_run: Annotated[
244
+ bool,
245
+ typer.Option("--dry-run", help="Preview files without writing them."),
246
+ ] = False,
247
+ json_output: Annotated[
248
+ bool,
249
+ typer.Option("--json", help="Print the generation plan as JSON."),
250
+ ] = False,
251
+ force: Annotated[
252
+ bool,
253
+ typer.Option("--force", help="Overwrite files if they already exist."),
254
+ ] = False,
255
+ allow_experimental_suggestions: Annotated[
256
+ bool,
257
+ typer.Option(
258
+ "--allow-experimental-suggestions",
259
+ help="Keep unsupported provider suggestions as notes instead of failing.",
260
+ ),
261
+ ] = False,
262
+ ) -> None:
263
+ """Generate a constrained FastAPI project from a natural-language prompt."""
264
+
265
+ resolved_output_dir = output_dir or Path.cwd()
266
+ try:
267
+ request = GenerationRequest(
268
+ prompt=prompt,
269
+ provider=provider,
270
+ model=model,
271
+ style=style, # type: ignore[arg-type]
272
+ project_name=project_name,
273
+ output_dir=resolved_output_dir,
274
+ yes=yes,
275
+ dry_run=dry_run,
276
+ json_output=json_output,
277
+ force=force,
278
+ allow_experimental_suggestions=allow_experimental_suggestions,
279
+ )
280
+ plan = build_generation_plan(request)
281
+ except (GenerationConstraintError, IntegrationError, ProviderError, ValueError) as exc:
282
+ console.print(f"[fastango.error]Error:[/] {exc}")
283
+ raise typer.Exit(code=1) from exc
284
+
285
+ if json_output:
286
+ console.print_json(plan_to_json(plan))
287
+ if dry_run:
288
+ return
289
+
290
+ if not json_output:
291
+ console.print(generation_preview(plan, output_dir=resolved_output_dir, force=force))
292
+ console.print(reasons_table(plan))
293
+ console.print(security_table(plan))
294
+
295
+ if (
296
+ not yes
297
+ and not dry_run
298
+ and not Confirm.ask(
299
+ "[fastango.title]Generate this constrained FastAPI project?[/]", default=True
300
+ )
301
+ ):
302
+ console.print("[fastango.muted]Cancelled.[/]")
303
+ raise typer.Exit
304
+
305
+ config = plan.to_project_config(output_dir=resolved_output_dir, force=force)
306
+ result = ScaffoldEngine().create(config, dry_run=dry_run)
307
+ if dry_run:
308
+ console.print(
309
+ f"[fastango.success]Dry run complete.[/] {len(result.files)} files would be generated:"
310
+ )
311
+ for path in result.files:
312
+ console.print(f" {path.relative_to(result.target_dir)}")
313
+ return
314
+
315
+ console.print(f"[fastango.success]Generated FastAPI project at {result.target_dir}[/]")
316
+ console.print("\nNext steps:")
317
+ console.print(f" cd {result.target_dir}")
318
+ console.print(" uv sync")
319
+ console.print(" cp .env.example .env")
320
+ console.print(" uv run fastapi dev app/main.py")
321
+
322
+
323
+ @app.command()
324
+ def create(
325
+ project_name: Annotated[
326
+ str | None,
327
+ typer.Argument(help="Project name and destination directory."),
328
+ ] = None,
329
+ package_name: Annotated[
330
+ str | None,
331
+ typer.Option("--package-name", help="Python package name for generated imports."),
332
+ ] = None,
333
+ output_dir: Annotated[
334
+ Path | None,
335
+ typer.Option("--output-dir", "-o", help="Parent directory for the generated project."),
336
+ ] = None,
337
+ style: Annotated[
338
+ str,
339
+ typer.Option("--style", help="Template style: simple or mvc."),
340
+ ] = "simple",
341
+ python_version: Annotated[
342
+ str,
343
+ typer.Option("--python", help="Python version for the generated project."),
344
+ ] = "3.12",
345
+ integration: Annotated[
346
+ list[str] | None,
347
+ typer.Option("--integration", "-i", help="Integration to include. Repeat this option."),
348
+ ] = None,
349
+ preset: Annotated[
350
+ list[str] | None,
351
+ typer.Option("--preset", "-p", help="Curated preset to include. Repeat this option."),
352
+ ] = None,
353
+ with_docker: Annotated[
354
+ bool,
355
+ typer.Option("--with-docker", help="Shortcut for --integration docker."),
356
+ ] = False,
357
+ create_git: Annotated[
358
+ bool,
359
+ typer.Option(
360
+ "--git/--no-git", help="Initialize a Git repository in the generated project."
361
+ ),
362
+ ] = False,
363
+ interactive: Annotated[
364
+ bool,
365
+ typer.Option("--interactive/--no-interactive", help="Prompt for missing choices."),
366
+ ] = True,
367
+ basic: Annotated[
368
+ bool,
369
+ typer.Option("--basic", help="Use basic prompts instead of the branded playground."),
370
+ ] = False,
371
+ dry_run: Annotated[
372
+ bool,
373
+ typer.Option("--dry-run", help="Print files that would be generated without writing them."),
374
+ ] = False,
375
+ force: Annotated[
376
+ bool,
377
+ typer.Option("--force", help="Overwrite files if they already exist."),
378
+ ] = False,
379
+ ) -> None:
380
+ """Create a new FastAPI project."""
381
+
382
+ registry = IntegrationRegistry.builtins()
383
+ resolved_output_dir = output_dir or Path.cwd()
384
+ selected_integrations = list(integration or [])
385
+ if with_docker:
386
+ selected_integrations.append("docker")
387
+
388
+ try:
389
+ if interactive and project_name is None and not basic:
390
+ config = run_playground(registry=registry, output_dir=resolved_output_dir, force=force)
391
+ if config is None:
392
+ console.print("[fastango.muted]Cancelled.[/]")
393
+ raise typer.Exit
394
+ elif interactive and project_name is None:
395
+ config = prompt_for_config(
396
+ project_name=project_name,
397
+ package_name=package_name,
398
+ output_dir=resolved_output_dir,
399
+ style=style,
400
+ python_version=python_version,
401
+ integrations=tuple(selected_integrations),
402
+ create_git=create_git,
403
+ force=force,
404
+ registry=registry,
405
+ )
406
+ else:
407
+ if project_name is None:
408
+ raise typer.BadParameter("PROJECT_NAME is required when --no-interactive is used.")
409
+ config = ProjectConfig(
410
+ project_name=project_name,
411
+ package_name=package_name,
412
+ output_dir=resolved_output_dir,
413
+ style=style, # type: ignore[arg-type]
414
+ python_version=python_version,
415
+ integrations=tuple(selected_integrations),
416
+ presets=tuple(preset or []),
417
+ create_git=create_git,
418
+ force=force,
419
+ )
420
+
421
+ result = ScaffoldEngine(registry=registry).create(config, dry_run=dry_run)
422
+ except (IntegrationError, ScaffoldWriteError, ValueError) as exc:
423
+ console.print(f"[fastango.error]Error:[/] {exc}")
424
+ raise typer.Exit(code=1) from exc
425
+
426
+ if dry_run:
427
+ console.print(
428
+ f"[fastango.success]Dry run complete.[/] {len(result.files)} files would be generated:"
429
+ )
430
+ for path in result.files:
431
+ console.print(f" {path.relative_to(result.target_dir)}")
432
+ return
433
+
434
+ console.print(f"[fastango.success]Created FastAPI project at {result.target_dir}[/]")
435
+ console.print("\nNext steps:")
436
+ console.print(f" cd {result.target_dir}")
437
+ console.print(" uv sync")
438
+ console.print(" cp .env.example .env")
439
+ console.print(" uv run fastapi dev app/main.py")
@@ -0,0 +1 @@
1
+ """Constrained prompt-to-project generation for Fastango."""
@@ -0,0 +1,77 @@
1
+ """Allowlist validation for generated Fastango plans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from fastango.generator.models import GenerationPlan
9
+ from fastango.generator.skills import skill_names
10
+ from fastango.scaffold.registry import IntegrationError, IntegrationRegistry
11
+
12
+ SUPPORTED_TEMPLATES = ("simple", "mvc")
13
+
14
+
15
+ class GenerationConstraintError(ValueError):
16
+ """Raised when a generated plan escapes Fastango's supported surface."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ProviderSuggestion:
21
+ skills: tuple[str, ...] = ()
22
+ presets: tuple[str, ...] = ()
23
+ integrations: tuple[str, ...] = ()
24
+ style: str | None = None
25
+ raw_code: str | None = None
26
+ notes: tuple[str, ...] = ()
27
+
28
+
29
+ def validate_provider_suggestion(
30
+ suggestion: ProviderSuggestion,
31
+ registry: IntegrationRegistry,
32
+ *,
33
+ allow_experimental_suggestions: bool = False,
34
+ ) -> tuple[str, ...]:
35
+ """Validate provider output and return unsupported notes."""
36
+
37
+ unsupported: list[str] = []
38
+ if suggestion.raw_code:
39
+ raise GenerationConstraintError("Provider output included raw code, which is not allowed.")
40
+ if suggestion.style and suggestion.style not in SUPPORTED_TEMPLATES:
41
+ raise GenerationConstraintError(f"Unsupported template '{suggestion.style}'.")
42
+ for skill in suggestion.skills:
43
+ if skill not in skill_names():
44
+ if allow_experimental_suggestions:
45
+ unsupported.append(f"Not generated: unsupported skill '{skill}'.")
46
+ else:
47
+ raise GenerationConstraintError(f"Unsupported skill '{skill}'.")
48
+ for preset in suggestion.presets:
49
+ try:
50
+ registry.get_preset(preset)
51
+ except IntegrationError as exc:
52
+ if allow_experimental_suggestions:
53
+ unsupported.append(f"Not generated: unsupported preset '{preset}'.")
54
+ else:
55
+ raise GenerationConstraintError(str(exc)) from exc
56
+ for integration in suggestion.integrations:
57
+ try:
58
+ registry.get(integration)
59
+ except IntegrationError as exc:
60
+ if allow_experimental_suggestions:
61
+ unsupported.append(f"Not generated: unsupported integration '{integration}'.")
62
+ else:
63
+ raise GenerationConstraintError(str(exc)) from exc
64
+ return tuple(unsupported)
65
+
66
+
67
+ def validate_generation_plan(plan: GenerationPlan, registry: IntegrationRegistry) -> None:
68
+ if plan.style not in SUPPORTED_TEMPLATES:
69
+ raise GenerationConstraintError(f"Unsupported template '{plan.style}'.")
70
+ for skill in plan.skills:
71
+ if skill not in skill_names():
72
+ raise GenerationConstraintError(f"Unsupported skill '{skill}'.")
73
+ for preset in plan.presets:
74
+ registry.get_preset(preset)
75
+ for integration in plan.integrations:
76
+ registry.get(integration)
77
+ registry.resolve(plan.to_project_config(output_dir=Path(".")))
@@ -0,0 +1,180 @@
1
+ """Supported LLM model catalog for optional generation providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from contextlib import suppress
7
+ from dataclasses import dataclass
8
+ from importlib import import_module
9
+ from typing import Any, Literal
10
+
11
+ from fastango.generator.providers.base import ProviderError
12
+
13
+ ModelProvider = Literal["anthropic", "openai"]
14
+ ModelSource = Literal["curated", "api"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SupportedModel:
19
+ provider: ModelProvider
20
+ model: str
21
+ label: str
22
+ description: str
23
+ default: bool = False
24
+ source: ModelSource = "curated"
25
+
26
+
27
+ SUPPORTED_MODELS: tuple[SupportedModel, ...] = (
28
+ SupportedModel(
29
+ provider="anthropic",
30
+ model="claude-3-5-haiku-latest",
31
+ label="Claude 3.5 Haiku",
32
+ description="Fast and cost-conscious planning for normal scaffolds.",
33
+ default=True,
34
+ ),
35
+ SupportedModel(
36
+ provider="anthropic",
37
+ model="claude-3-5-sonnet-latest",
38
+ label="Claude 3.5 Sonnet",
39
+ description="Stronger reasoning for larger MVP prompts.",
40
+ ),
41
+ SupportedModel(
42
+ provider="anthropic",
43
+ model="claude-3-7-sonnet-latest",
44
+ label="Claude 3.7 Sonnet",
45
+ description="Advanced planning for complex product scaffolds.",
46
+ ),
47
+ SupportedModel(
48
+ provider="openai",
49
+ model="gpt-4.1-mini",
50
+ label="GPT-4.1 mini",
51
+ description="Fast and cost-conscious planning for normal scaffolds.",
52
+ default=True,
53
+ ),
54
+ SupportedModel(
55
+ provider="openai",
56
+ model="gpt-4.1",
57
+ label="GPT-4.1",
58
+ description="Stronger planning for more complex MVP prompts.",
59
+ ),
60
+ SupportedModel(
61
+ provider="openai",
62
+ model="gpt-4o-mini",
63
+ label="GPT-4o mini",
64
+ description="General-purpose planning with broad availability.",
65
+ ),
66
+ )
67
+
68
+
69
+ def models_for_provider(provider: ModelProvider | None = None) -> tuple[SupportedModel, ...]:
70
+ if provider is None:
71
+ return SUPPORTED_MODELS
72
+ return tuple(model for model in SUPPORTED_MODELS if model.provider == provider)
73
+
74
+
75
+ def models_from_api(provider: ModelProvider) -> tuple[SupportedModel, ...]:
76
+ if provider == "anthropic":
77
+ return _anthropic_models_from_api()
78
+ return _openai_models_from_api()
79
+
80
+
81
+ def available_models(
82
+ provider: ModelProvider | None = None,
83
+ *,
84
+ live: bool = True,
85
+ ) -> tuple[SupportedModel, ...]:
86
+ if not live:
87
+ return models_for_provider(provider)
88
+ if provider is not None:
89
+ try:
90
+ return models_from_api(provider)
91
+ except ProviderError:
92
+ return models_for_provider(provider)
93
+
94
+ models: list[SupportedModel] = []
95
+ for candidate in ("anthropic", "openai"):
96
+ try:
97
+ models.extend(models_from_api(candidate))
98
+ except ProviderError:
99
+ models.extend(models_for_provider(candidate))
100
+ return tuple(models)
101
+
102
+
103
+ def default_model(provider: ModelProvider) -> str:
104
+ for model in SUPPORTED_MODELS:
105
+ if model.provider == provider and model.default:
106
+ return model.model
107
+ return models_for_provider(provider)[0].model
108
+
109
+
110
+ def validate_model(provider: ModelProvider, model: str | None) -> str:
111
+ selected = model or default_model(provider)
112
+ supported = {item.model for item in models_for_provider(provider)}
113
+ if selected not in supported:
114
+ with suppress(ProviderError):
115
+ supported.update(item.model for item in models_from_api(provider))
116
+ if selected not in supported:
117
+ available = ", ".join(sorted(supported))
118
+ raise ProviderError(
119
+ f"Model '{selected}' is not supported for provider '{provider}'. Available: {available}."
120
+ )
121
+ return selected
122
+
123
+
124
+ def _httpx() -> Any:
125
+ try:
126
+ return import_module("httpx")
127
+ except ImportError as exc: # pragma: no cover - optional dependency path.
128
+ raise ProviderError(
129
+ "Install Fastango with the `ai` extra to discover provider models."
130
+ ) from exc
131
+
132
+
133
+ def _anthropic_models_from_api() -> tuple[SupportedModel, ...]:
134
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
135
+ if not api_key:
136
+ raise ProviderError("ANTHROPIC_API_KEY is required to discover Anthropic models.")
137
+ response = _httpx().get(
138
+ "https://api.anthropic.com/v1/models",
139
+ headers={
140
+ "x-api-key": api_key,
141
+ "anthropic-version": "2023-06-01",
142
+ "content-type": "application/json",
143
+ },
144
+ timeout=30,
145
+ )
146
+ response.raise_for_status()
147
+ return tuple(
148
+ SupportedModel(
149
+ provider="anthropic",
150
+ model=str(item["id"]),
151
+ label=str(item.get("display_name") or item["id"]),
152
+ description="Discovered from the configured Anthropic API key.",
153
+ source="api",
154
+ )
155
+ for item in response.json().get("data", [])
156
+ if item.get("id")
157
+ )
158
+
159
+
160
+ def _openai_models_from_api() -> tuple[SupportedModel, ...]:
161
+ api_key = os.environ.get("OPENAI_API_KEY")
162
+ if not api_key:
163
+ raise ProviderError("OPENAI_API_KEY is required to discover OpenAI models.")
164
+ response = _httpx().get(
165
+ "https://api.openai.com/v1/models",
166
+ headers={"authorization": f"Bearer {api_key}", "content-type": "application/json"},
167
+ timeout=30,
168
+ )
169
+ response.raise_for_status()
170
+ return tuple(
171
+ SupportedModel(
172
+ provider="openai",
173
+ model=str(item["id"]),
174
+ label=str(item["id"]),
175
+ description="Discovered from the configured OpenAI API key.",
176
+ source="api",
177
+ )
178
+ for item in response.json().get("data", [])
179
+ if item.get("id")
180
+ )