pgate 0.1.1__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.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: pgate
3
+ Version: 0.1.1
4
+ Summary: PGate — Prompt ORM for LLM agents. Store, compile, validate and cache prompt contracts locally.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: jinja2>=3.1
9
+ Requires-Dist: jsonschema>=4.0
10
+ Requires-Dist: loguru>=0.7
11
+ Requires-Dist: mcp>=1.0
12
+ Requires-Dist: pydantic>=2.0
13
+ Requires-Dist: pyyaml>=6.0
14
+ Provides-Extra: crypto
15
+ Requires-Dist: cryptography>=41.0; extra == 'crypto'
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
18
+ Requires-Dist: pytest>=7.0; extra == 'dev'
19
+ Provides-Extra: litellm
20
+ Requires-Dist: litellm>=1.0; extra == 'litellm'
21
+ Provides-Extra: postgres
22
+ Requires-Dist: psycopg2-binary>=2.9; extra == 'postgres'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # PGate
26
+
27
+ **Prompt ORM for LLM agents.** Store, find, compile, validate, and cache prompt contracts — locally, without cloud, without telemetry.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install pgate
33
+ ```
34
+
35
+ ## Quickstart
36
+
37
+ ```bash
38
+ pgate init
39
+ pgate add --file examples/report_sales.yaml
40
+ pgate compile --interactive
41
+ ```
42
+
43
+ ## MCP (Claude Desktop / Cursor)
44
+
45
+ Add to `claude_desktop_config.json`:
46
+
47
+ ```json
48
+ {
49
+ "pgate": {
50
+ "command": "pgate",
51
+ "args": ["mcp", "--stdio"]
52
+ }
53
+ }
54
+ ```
55
+
56
+ ## Status
57
+
58
+ v0.1 — in development.
@@ -0,0 +1,16 @@
1
+ promptgate/__init__.py,sha256=fa4qeq-zQCkXCJd9jWAE7bvX8NV-jO44hBkzAiSnBvU,251
2
+ promptgate/cli.py,sha256=fBQtcqZXk8rUN7snZgDsO-8xnxvdxS1ma5Cc94YeIcE,12652
3
+ promptgate/compiler.py,sha256=9gDVmYBhlCl6zNTtcFwLZRvz85SLaVyQgaxASViQYA0,7697
4
+ promptgate/file_api.py,sha256=AtE_j0M48euY8O2G_1vkrcJ4xyKwViwLfJrMsbgcLYI,4704
5
+ promptgate/mcp_adapter.py,sha256=QUZ5s7ZH4PDahKPzqFBkRDN0oKq9UGMvKB27n27IsJY,3006
6
+ promptgate/models.py,sha256=AdvcOGW5kLJ46MriLNHfnok5YTOO9RluoAndDfGSsKk,2391
7
+ promptgate/models_registry.py,sha256=baZIgPD_kYw_mwp2FdL-VIc0F6spScZHF16nDTsBdLY,12559
8
+ promptgate/router.py,sha256=7uxQH_04sY8ovyaR3mT6MXFWjuNKo67hrZ_y3VDJdF0,2706
9
+ promptgate/storage.py,sha256=ENtPQNkkbleZSYWKDytk4XqKQa1XRQuB5dzbOZFzPr4,8589
10
+ promptgate/validator.py,sha256=PlwIwXFlQQVgb_AxBhd5Up6n2OUd0xmXVRvzDpvEpO4,3415
11
+ promptgate/backends/__init__.py,sha256=Ep-Z-N3bnmXdkxgZ_xm4GXFRpW_S8lCoxH04n-NNZF8,63
12
+ promptgate/backends/base.py,sha256=vAiRaogukSlfOuDgWJDxtKYP0zNaE6YkiJhtHGfIYm0,515
13
+ pgate-0.1.1.dist-info/METADATA,sha256=Epu8-jZ3U6YI_pMHcSSrKQYP6oC0mQ6CFS0CLjcoygA,1263
14
+ pgate-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ pgate-0.1.1.dist-info/entry_points.txt,sha256=WrYmMfktlR1LioEJxmCiMOXM5-x5k77HqrimDYaQf5U,79
16
+ pgate-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pgate = promptgate.cli:main
3
+ promptgate = promptgate.cli:main
promptgate/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .models import CompiledContract, PromptConfig, ValidationResult
2
+ from .file_api import get_or_compile
3
+ from .router import search
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["PromptConfig", "CompiledContract", "ValidationResult", "get_or_compile", "search"]
@@ -0,0 +1,3 @@
1
+ from .base import StorageBackend
2
+
3
+ __all__ = ["StorageBackend"]
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from promptgate.models import PromptConfig
6
+
7
+
8
+ @runtime_checkable
9
+ class StorageBackend(Protocol):
10
+ def init(self) -> None: ...
11
+ def upsert(self, prompt: PromptConfig) -> None: ...
12
+ def get(self, prompt_id: str) -> PromptConfig | None: ...
13
+ def delete(self, prompt_id: str) -> bool: ...
14
+ def list_all(self) -> list[PromptConfig]: ...
15
+ def search_fts(self, query: str, limit: int = 5) -> list[tuple[str, float]]: ...
promptgate/cli.py ADDED
@@ -0,0 +1,365 @@
1
+ """Click CLI: init, add, search, compile, mcp."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import yaml
10
+ from loguru import logger
11
+
12
+ from promptgate.file_api import get_or_compile, purge_stale
13
+ from promptgate.models import PromptConfig
14
+ from promptgate.router import search as pg_search
15
+ from promptgate.storage import SQLiteBackend, _DEFAULT_DB_PATH
16
+
17
+
18
+ def _configure_logging(verbose: bool) -> None:
19
+ logger.remove()
20
+ level = "DEBUG" if verbose else "INFO"
21
+ logger.add(sys.stderr, level=level, format="<level>{level}</level> {message}")
22
+ log_dir = Path("logs")
23
+ log_dir.mkdir(exist_ok=True)
24
+ logger.add(
25
+ log_dir / "app.log",
26
+ level="DEBUG",
27
+ rotation="10 MB",
28
+ retention="7 days",
29
+ encoding="utf-8",
30
+ )
31
+
32
+
33
+ @click.group()
34
+ @click.option("--verbose", "-v", is_flag=True, default=False, help="Enable debug logging.")
35
+ @click.option(
36
+ "--db",
37
+ default=str(_DEFAULT_DB_PATH),
38
+ show_default=True,
39
+ help="Path to SQLite database.",
40
+ )
41
+ @click.pass_context
42
+ def main(ctx: click.Context, verbose: bool, db: str) -> None:
43
+ """PromptGate - Prompt ORM for LLM agents."""
44
+ ctx.ensure_object(dict)
45
+ ctx.obj["db"] = db
46
+ _configure_logging(verbose)
47
+
48
+
49
+ @main.command()
50
+ @click.pass_context
51
+ def init(ctx: click.Context) -> None:
52
+ """Initialize the PromptGate database.
53
+
54
+ Creates ~/.promptgate/db.sqlite and FTS5 tables if they don't exist.
55
+ """
56
+ backend = SQLiteBackend(ctx.obj["db"])
57
+ backend.init()
58
+ click.echo(f"Initialized: {ctx.obj['db']}")
59
+
60
+
61
+ @main.command("add")
62
+ @click.option("--file", "-f", "file_path", type=click.Path(exists=True), help="Path to YAML prompt file.")
63
+ @click.option("--url", "-u", help="URL to fetch YAML prompt from.")
64
+ @click.pass_context
65
+ def add_prompt(ctx: click.Context, file_path: str | None, url: str | None) -> None:
66
+ """Add or update a prompt from a YAML file or URL.
67
+
68
+ Examples:
69
+
70
+ promptgate add --file examples/report_sales.yaml
71
+
72
+ promptgate add --url https://example.com/my_prompt.yaml
73
+ """
74
+ if not file_path and not url:
75
+ raise click.UsageError("Provide --file or --url.")
76
+
77
+ raw: str
78
+ if url:
79
+ import urllib.request
80
+ with urllib.request.urlopen(url) as resp: # noqa: S310
81
+ raw = resp.read().decode("utf-8")
82
+ else:
83
+ raw = Path(file_path).read_text(encoding="utf-8")
84
+
85
+ data = yaml.safe_load(raw)
86
+ prompt = PromptConfig.model_validate(data)
87
+
88
+ backend = SQLiteBackend(ctx.obj["db"])
89
+ backend.init()
90
+ backend.upsert(prompt)
91
+ click.echo(f"Added prompt: {prompt.id} ({prompt.name})")
92
+
93
+
94
+ @main.command("search")
95
+ @click.argument("query")
96
+ @click.option("--limit", "-n", default=5, show_default=True, help="Max results.")
97
+ @click.pass_context
98
+ def search_cmd(ctx: click.Context, query: str, limit: int) -> None:
99
+ """Search prompts by full-text relevance.
100
+
101
+ Examples:
102
+
103
+ promptgate search "sales report"
104
+ """
105
+ results = pg_search(query, db_path=ctx.obj["db"], limit=limit)
106
+ if not results:
107
+ click.echo("No results.")
108
+ return
109
+ for prompt_id, score in results:
110
+ click.echo(f" {score:.4f} {prompt_id}")
111
+
112
+
113
+ @main.command("compile")
114
+ @click.argument("prompt_id")
115
+ @click.option("--payload", "-p", default="{}", help="JSON payload string.")
116
+ @click.option("--model", "-m", default=None, help="LLM model ID for model-aware compilation.")
117
+ @click.option("--interactive", "-i", is_flag=True, help="Prompt for each required field interactively.")
118
+ @click.pass_context
119
+ def compile_cmd(ctx: click.Context, prompt_id: str, payload: str, model: str | None, interactive: bool) -> None:
120
+ """Compile a prompt and print the system_prompt.
121
+
122
+ Examples:
123
+
124
+ promptgate compile sales_v1 --payload '{"period": "2024-01"}'
125
+
126
+ promptgate compile sales_v1 --model gpt-4o --payload '{"period": "2024-01"}'
127
+
128
+ promptgate compile sales_v1 --interactive
129
+ """
130
+ backend = SQLiteBackend(ctx.obj["db"])
131
+ backend.init()
132
+ prompt = backend.get(prompt_id)
133
+ if prompt is None:
134
+ raise click.ClickException(f"Prompt '{prompt_id}' not found.")
135
+
136
+ data: dict = json.loads(payload)
137
+
138
+ if interactive:
139
+ required = prompt.schema_.get("required", [])
140
+ props = prompt.schema_.get("properties", {})
141
+ for field in required:
142
+ if field not in data:
143
+ desc = props.get(field, {})
144
+ hint = desc.get("type", "string")
145
+ value = click.prompt(f" {field} ({hint})")
146
+ data[field] = value
147
+
148
+ contract = get_or_compile(prompt_id, data, db_path=ctx.obj["db"], model_id=model)
149
+ click.echo(contract.system_prompt)
150
+ if contract.missing_fields:
151
+ click.echo(f"\n[missing: {', '.join(contract.missing_fields)}]", err=True)
152
+ if contract.model_hints:
153
+ h = contract.model_hints
154
+ rm_tag = {"none": "", "chain_of_thought": f" [CoT:{h.cot_strategy}]", "native": " [think]"}.get(
155
+ h.reasoning_mode, ""
156
+ )
157
+ sys_tag = "" if h.supports_system_prompt else " [no-sys]"
158
+ logger.debug("Model: {} ({}) ctx={}k{}{}", h.model_id, h.family, h.context_window // 1000, rm_tag, sys_tag)
159
+
160
+
161
+ @main.group("models", invoke_without_command=True)
162
+ @click.pass_context
163
+ def models_group(ctx: click.Context) -> None:
164
+ """Manage LLM model registry.
165
+
166
+ Without a subcommand, lists all registered models.
167
+
168
+ Examples:
169
+
170
+ promptgate models
171
+
172
+ promptgate models list --family anthropic
173
+
174
+ promptgate models add --id gpt-5 --family openai --ctx 400000 --reasoning native
175
+
176
+ promptgate models rm gpt-5
177
+
178
+ promptgate models show gpt-4o
179
+ """
180
+ if ctx.invoked_subcommand is None:
181
+ ctx.invoke(models_list)
182
+
183
+
184
+ def _print_models_table(rows: list[tuple[str, object]], custom_ids: set[str]) -> None:
185
+ """Print a formatted table row for each (model_id, ModelCapabilities) pair."""
186
+ from promptgate.models_registry import ModelCapabilities as MC
187
+ rm_labels = {"none": " ", "chain_of_thought": " CoT ", "native": "think"}
188
+ for name, caps in rows:
189
+ rm = rm_labels.get(caps.reasoning_mode, " ")
190
+ cot = f"/{caps.cot_strategy}" if caps.reasoning_mode == "chain_of_thought" else ""
191
+ sys_tag = " " if caps.supports_system_prompt else "NS"
192
+ json_tag = "J" if caps.json_native else " "
193
+ ctx_k = caps.context_window // 1000
194
+ custom_tag = "*" if name in custom_ids else " "
195
+ click.echo(f" {custom_tag}[{rm}{cot:<12}] [{sys_tag}][{json_tag}] ctx={ctx_k:>5}k {name}")
196
+
197
+
198
+ @models_group.command("list")
199
+ @click.option("--family", "-f", default=None, help="Filter by family (openai, anthropic, deepseek...)")
200
+ @click.option("--reasoning", "-r", is_flag=True, help="Show only models with reasoning support.")
201
+ @click.option("--custom", "-c", is_flag=True, help="Show only user-defined models.")
202
+ def models_list(family: str | None, reasoning: bool, custom: bool) -> None:
203
+ """List registered LLM models and their capabilities.
204
+
205
+ A leading ``*`` marks user-defined models.
206
+
207
+ Examples:
208
+
209
+ promptgate models list
210
+
211
+ promptgate models list --family anthropic
212
+
213
+ promptgate models list --reasoning
214
+
215
+ promptgate models list --custom
216
+ """
217
+ from promptgate.models_registry import _REGISTRY, _USER_REGISTRY
218
+
219
+ all_rows: dict = {**_REGISTRY, **_USER_REGISTRY} # user overrides win
220
+ rows = sorted(all_rows.items())
221
+ if family:
222
+ rows = [(k, v) for k, v in rows if v.family == family]
223
+ if reasoning:
224
+ rows = [(k, v) for k, v in rows if v.reasoning_mode != "none"]
225
+ if custom:
226
+ rows = [(k, v) for k, v in rows if k in _USER_REGISTRY]
227
+
228
+ if not rows:
229
+ click.echo("No models match the filter.")
230
+ return
231
+ _print_models_table(rows, set(_USER_REGISTRY.keys()))
232
+
233
+
234
+ @models_group.command("add")
235
+ @click.option("--id", "model_id", required=True, help="Unique model identifier.")
236
+ @click.option("--family", "-f", required=True, help="Provider family (openai, anthropic, deepseek...)")
237
+ @click.option("--ctx", "context_window", required=True, type=int, help="Context window in tokens.")
238
+ @click.option(
239
+ "--reasoning", "-r", default="none",
240
+ type=click.Choice(["none", "chain_of_thought", "native"]),
241
+ help="Reasoning mode.",
242
+ )
243
+ @click.option(
244
+ "--cot-strategy", default="zero_shot",
245
+ type=click.Choice(["zero_shot", "structured", "react"]),
246
+ help="CoT algorithm for chain_of_thought models.",
247
+ )
248
+ @click.option("--json-native", is_flag=True, help="Model supports native JSON output mode.")
249
+ @click.option("--no-system-prompt", is_flag=True, help="Model does not accept a system prompt.")
250
+ @click.option("--reasoning-budget", type=int, default=None, help="Token budget for native reasoning.")
251
+ @click.option("--max-output", type=int, default=4096, help="Max output tokens.")
252
+ def models_add(
253
+ model_id: str,
254
+ family: str,
255
+ context_window: int,
256
+ reasoning: str,
257
+ cot_strategy: str,
258
+ json_native: bool,
259
+ no_system_prompt: bool,
260
+ reasoning_budget: int | None,
261
+ max_output: int,
262
+ ) -> None:
263
+ """Add or override a model in the user registry.
264
+
265
+ Writes to ``~/.promptgate/models.yaml``.
266
+
267
+ Examples:
268
+
269
+ promptgate models add --id gpt-5 --family openai --ctx 400000 --reasoning native
270
+
271
+ promptgate models add --id my-llm --family custom --ctx 8000 --reasoning chain_of_thought --cot-strategy structured
272
+ """
273
+ from promptgate.models_registry import ModelCapabilities, register_model
274
+
275
+ caps = ModelCapabilities(
276
+ family=family,
277
+ context_window=context_window,
278
+ reasoning_mode=reasoning,
279
+ cot_strategy=cot_strategy,
280
+ json_native=json_native,
281
+ supports_system_prompt=not no_system_prompt,
282
+ reasoning_budget_tokens=reasoning_budget,
283
+ max_output_tokens=max_output,
284
+ )
285
+ register_model(model_id, caps)
286
+ click.echo(f"Registered '{model_id}' ({family}, ctx={context_window:,}) → ~/.promptgate/models.yaml")
287
+
288
+
289
+ @models_group.command("rm")
290
+ @click.argument("model_id")
291
+ def models_rm(model_id: str) -> None:
292
+ """Remove a custom model from the user registry.
293
+
294
+ Only user-defined models can be removed; built-in entries are protected.
295
+
296
+ Examples:
297
+
298
+ promptgate models rm my-custom-llm
299
+ """
300
+ from promptgate.models_registry import unregister_model
301
+
302
+ if not unregister_model(model_id):
303
+ raise click.ClickException(
304
+ f"'{model_id}' not found in user registry. Built-in models cannot be removed."
305
+ )
306
+ click.echo(f"Removed '{model_id}' from ~/.promptgate/models.yaml")
307
+
308
+
309
+ @models_group.command("show")
310
+ @click.argument("model_id")
311
+ def models_show(model_id: str) -> None:
312
+ """Show full capabilities for a specific model.
313
+
314
+ Examples:
315
+
316
+ promptgate models show gpt-4o
317
+
318
+ promptgate models show my-custom-llm
319
+ """
320
+ from promptgate.models_registry import _USER_REGISTRY, resolve_model
321
+
322
+ caps = resolve_model(model_id)
323
+ if caps is None:
324
+ raise click.ClickException(f"Unknown model: '{model_id}'")
325
+
326
+ is_custom = model_id in _USER_REGISTRY
327
+ source = "user-defined" if is_custom else "built-in"
328
+ click.echo(f" model_id: {model_id} [{source}]")
329
+ click.echo(f" family: {caps.family}")
330
+ click.echo(f" context_window: {caps.context_window:,}")
331
+ click.echo(f" max_output_tokens: {caps.max_output_tokens:,}")
332
+ click.echo(f" reasoning_mode: {caps.reasoning_mode}")
333
+ click.echo(f" cot_strategy: {caps.cot_strategy}")
334
+ click.echo(f" json_native: {caps.json_native}")
335
+ click.echo(f" supports_system_prompt: {caps.supports_system_prompt}")
336
+ if caps.reasoning_budget_tokens is not None:
337
+ click.echo(f" reasoning_budget_tokens: {caps.reasoning_budget_tokens:,}")
338
+
339
+
340
+ @main.command("mcp")
341
+ @click.option("--stdio", "transport", flag_value="stdio", default=True, help="Run MCP over stdio.")
342
+ @click.pass_context
343
+ def mcp_cmd(ctx: click.Context, transport: str) -> None:
344
+ """Start the PromptGate MCP server.
345
+
346
+ Add to Claude Desktop config:
347
+
348
+ {\"command\": \"promptgate\", \"args\": [\"mcp\", \"--stdio\"]}
349
+ """
350
+ from promptgate.mcp_adapter import run_stdio
351
+ run_stdio(db_path=ctx.obj["db"])
352
+
353
+
354
+ @main.command("purge")
355
+ @click.option("--days", default=7, show_default=True, help="Delete contracts older than N days.")
356
+ @click.pass_context
357
+ def purge_cmd(ctx: click.Context, days: int) -> None:
358
+ """Delete stale compiled contract files.
359
+
360
+ Examples:
361
+
362
+ promptgate purge --days 3
363
+ """
364
+ deleted = purge_stale(max_age_seconds=days * 86400)
365
+ click.echo(f"Deleted {deleted} stale contract file(s).")
promptgate/compiler.py ADDED
@@ -0,0 +1,195 @@
1
+ """Pydantic TypeAdapter + Jinja2 -> CompiledContract."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import time
7
+
8
+ import jinja2.nodes
9
+ from jinja2 import ChainableUndefined, Environment, StrictUndefined, UndefinedError
10
+ from pydantic import TypeAdapter, ValidationError
11
+
12
+ from promptgate.models import CompiledContract, ModelHints, PromptConfig
13
+
14
+
15
+ _jinja_env = Environment(undefined=StrictUndefined, autoescape=False)
16
+ _jinja_env_lenient = Environment(undefined=ChainableUndefined, autoescape=False)
17
+ _jinja_env_parse = Environment(autoescape=False) # AST analysis only — no rendering
18
+
19
+ # Chain-of-thought injection templates by strategy.
20
+ # Applied automatically when model.reasoning_mode == "chain_of_thought" and
21
+ # the template does not already reference the `reasoning_mode` variable.
22
+ _COT_TEMPLATES: dict[str, str] = {
23
+ # Kojima et al. 2022 — reliable for most instruction-tuned models
24
+ "zero_shot": "Think step by step before giving your final answer.\n\n",
25
+ # Numbered analysis — better for analytical / long-form tasks
26
+ "structured": (
27
+ "Work through this task step by step:\n"
28
+ "1. Understand exactly what is being asked\n"
29
+ "2. Identify all relevant information\n"
30
+ "3. Reason through the solution carefully\n"
31
+ "4. Formulate and verify your final answer\n\n"
32
+ ),
33
+ # Yao et al. ReAct — best for tool-use and agentic tasks
34
+ "react": (
35
+ "Use the following format for each reasoning step:\n"
36
+ "Thought: [your reasoning about the current step]\n"
37
+ "Action: [what you determine or decide]\n"
38
+ "Observation: [what you learn from that action]\n"
39
+ "... (repeat Thought/Action/Observation as needed)\n"
40
+ "Final Answer: [your conclusion]\n\n"
41
+ ),
42
+ }
43
+
44
+
45
+ def _schema_hash(schema: dict) -> str:
46
+ raw = json.dumps(schema, sort_keys=True, ensure_ascii=False)
47
+ return hashlib.sha256(raw.encode()).hexdigest()[:8]
48
+
49
+
50
+ def _find_missing(schema: dict, payload: dict) -> list[str]:
51
+ required = schema.get("required", [])
52
+ return [f for f in required if f not in payload]
53
+
54
+
55
+ def _template_references_var(template_str: str, var_name: str) -> bool:
56
+ """Return True if the Jinja2 template AST contains a reference to var_name.
57
+
58
+ Parses without rendering — checks Name nodes in the AST so both
59
+ ``{{ var_name }}`` and ``{% if var_name %}`` are detected correctly.
60
+
61
+ Args:
62
+ template_str: Raw Jinja2 template string.
63
+ var_name: Variable name to search for.
64
+
65
+ Returns:
66
+ True if the variable is referenced anywhere in the template.
67
+
68
+ Examples:
69
+ >>> _template_references_var("Hello {{ reasoning_mode }}", "reasoning_mode")
70
+ True
71
+ >>> _template_references_var("Hello world", "reasoning_mode")
72
+ False
73
+ """
74
+ ast = _jinja_env_parse.parse(template_str)
75
+ return any(node.name == var_name for node in ast.find_all(jinja2.nodes.Name))
76
+
77
+
78
+ def compile_prompt(prompt: PromptConfig, payload: dict, model_id: str | None = None) -> CompiledContract:
79
+ """Validate payload against schema and render the Jinja2 template.
80
+
81
+ When ``model_id`` is provided, model capability vars are injected into the
82
+ Jinja2 context (``reasoning_mode``, ``model_family``, ``context_window``,
83
+ ``json_native``, ``model_id``). If the model requires chain-of-thought
84
+ reasoning and the template does not reference ``reasoning_mode``, the
85
+ appropriate CoT algorithm prefix is prepended automatically.
86
+
87
+ Args:
88
+ prompt: The prompt configuration containing schema and template.
89
+ payload: Key-value data to fill into the template.
90
+ model_id: Optional LLM model identifier. Enables model-aware compilation
91
+ and CoT auto-injection for ``chain_of_thought`` models.
92
+
93
+ Returns:
94
+ CompiledContract with rendered system_prompt, schema_hash,
95
+ model_hints (if model_id provided), and any missing required fields.
96
+
97
+ Raises:
98
+ ValueError: If the Jinja2 template references an undefined variable
99
+ that is not in the payload and not in missing_fields.
100
+
101
+ Examples:
102
+ >>> from promptgate.models import PromptConfig
103
+ >>> p = PromptConfig(
104
+ ... id="t", name="T",
105
+ ... **{"schema": {"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}},
106
+ ... template="Hello {{ x }}",
107
+ ... )
108
+ >>> contract = compile_prompt(p, {"x": "world"})
109
+ >>> contract.system_prompt
110
+ 'Hello world'
111
+ >>> contract = compile_prompt(p, {"x": "world"}, model_id="gpt-4o")
112
+ >>> contract.model_hints.family
113
+ 'openai'
114
+ """
115
+ from promptgate.models_registry import resolve_model
116
+
117
+ schema = prompt.schema_
118
+ sh = _schema_hash(schema)
119
+ missing = _find_missing(schema, payload)
120
+
121
+ # Resolve model capabilities (None if model_id unknown or not given)
122
+ caps = resolve_model(model_id) if model_id else None
123
+
124
+ # Coerce payload fields declared in schema using Pydantic TypeAdapter
125
+ coerced: dict = {}
126
+ props = schema.get("properties", {})
127
+ for key, value in payload.items():
128
+ if key in props:
129
+ try:
130
+ ta = TypeAdapter(type(value))
131
+ coerced[key] = ta.validate_python(value)
132
+ except (ValidationError, Exception):
133
+ coerced[key] = value
134
+ else:
135
+ coerced[key] = value
136
+
137
+ ctx = {
138
+ **coerced,
139
+ "payload_json": json.dumps(coerced, ensure_ascii=False, indent=2),
140
+ "output_schema": json.dumps(schema, ensure_ascii=False),
141
+ "missing_fields": missing,
142
+ # Model context vars — safe defaults when no model specified
143
+ "model_id": model_id or "",
144
+ "model_family": caps.family if caps else "",
145
+ "reasoning_mode": caps.reasoning_mode if caps else "none",
146
+ "context_window": caps.context_window if caps else 0,
147
+ "json_native": caps.json_native if caps else False,
148
+ }
149
+
150
+ try:
151
+ if missing:
152
+ # Missing required fields — use lenient rendering so contract is still created
153
+ tmpl = _jinja_env_lenient.from_string(prompt.template)
154
+ rendered = tmpl.render(**ctx)
155
+ else:
156
+ tmpl = _jinja_env.from_string(prompt.template)
157
+ rendered = tmpl.render(**ctx)
158
+ except UndefinedError as exc:
159
+ raise ValueError(f"Template variable error: {exc}") from exc
160
+
161
+ # Auto-inject CoT prefix when:
162
+ # 1. Model needs chain_of_thought reasoning
163
+ # 2. Template does not already reference reasoning_mode (user controls manually)
164
+ if (
165
+ caps is not None
166
+ and caps.reasoning_mode == "chain_of_thought"
167
+ and not _template_references_var(prompt.template, "reasoning_mode")
168
+ ):
169
+ cot_prefix = _COT_TEMPLATES.get(caps.cot_strategy, _COT_TEMPLATES["zero_shot"])
170
+ rendered = cot_prefix + rendered
171
+
172
+ model_hints: ModelHints | None = None
173
+ if caps is not None:
174
+ model_hints = ModelHints(
175
+ model_id=model_id, # type: ignore[arg-type]
176
+ family=caps.family,
177
+ reasoning_mode=caps.reasoning_mode,
178
+ cot_strategy=caps.cot_strategy,
179
+ context_window=caps.context_window,
180
+ max_output_tokens=caps.max_output_tokens,
181
+ json_native=caps.json_native,
182
+ supports_system_prompt=caps.supports_system_prompt,
183
+ reasoning_budget_tokens=caps.reasoning_budget_tokens,
184
+ )
185
+
186
+ return CompiledContract(
187
+ prompt_id=prompt.id,
188
+ schema_hash=sh,
189
+ compiled_at=int(time.time()),
190
+ system_prompt=rendered,
191
+ payload=coerced,
192
+ missing_fields=missing,
193
+ model_id=model_id,
194
+ model_hints=model_hints,
195
+ )