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.
- pgate-0.1.1.dist-info/METADATA +58 -0
- pgate-0.1.1.dist-info/RECORD +16 -0
- pgate-0.1.1.dist-info/WHEEL +4 -0
- pgate-0.1.1.dist-info/entry_points.txt +3 -0
- promptgate/__init__.py +6 -0
- promptgate/backends/__init__.py +3 -0
- promptgate/backends/base.py +15 -0
- promptgate/cli.py +365 -0
- promptgate/compiler.py +195 -0
- promptgate/file_api.py +143 -0
- promptgate/mcp_adapter.py +99 -0
- promptgate/models.py +75 -0
- promptgate/models_registry.py +293 -0
- promptgate/router.py +81 -0
- promptgate/storage.py +247 -0
- promptgate/validator.py +103 -0
|
@@ -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,,
|
promptgate/__init__.py
ADDED
|
@@ -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
|
+
)
|