Skip to content

Commit 7d1a3bd

Browse files
committed
fix(prompts,chat,dashboard): validate prompt inputs, fix /model provider stickiness, surface dashboard auth gap
1 parent 5793c91 commit 7d1a3bd

18 files changed

Lines changed: 632 additions & 16 deletions

effgen/cli/_main.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1170,10 +1170,17 @@ def serve_api(self, args):
11701170
elif oidc:
11711171
self.print_success("Auth: OIDC / JWT")
11721172

1173+
public_dashboard = dev_mode or os.environ.get(
1174+
"EFFGEN_PUBLIC_DASHBOARD", "0"
1175+
).strip() == "1"
1176+
11731177
self.print(f"Starting server on {host}:{port}")
11741178
self.print(f" OpenAI-compatible API : http://{host}:{port}/v1")
11751179
self.print(f" Interactive docs : http://{host}:{port}/docs")
1176-
self.print(f" Dashboard : http://{host}:{port}/dashboard")
1180+
dashboard_line = f" Dashboard : http://{host}:{port}/dashboard"
1181+
if not public_dashboard:
1182+
dashboard_line += " (data requires an API key; set EFFGEN_PUBLIC_DASHBOARD=1 for local viewing)"
1183+
self.print(dashboard_line)
11771184
self.print()
11781185

11791186
uvicorn.run(
@@ -2666,6 +2673,10 @@ def create_parser():
26662673
" EFFGEN_OIDC_ISSUER / enable OIDC/JWT auth instead of a static key.\n"
26672674
" EFFGEN_OIDC_CLIENT_ID\n"
26682675
" EFFGEN_PUBLIC_METRICS=1 serve /metrics without auth (default: auth).\n"
2676+
" EFFGEN_PUBLIC_DASHBOARD=1 serve /dashboard/data.json + /dashboard/spans\n"
2677+
" without auth, for local viewing (default: auth;\n"
2678+
" the /dashboard page itself always loads, but its\n"
2679+
" data calls 401 without this or an API key).\n"
26692680
" EFFGEN_MODEL_POOL_SIZE loaded models kept warm (default 4).\n"
26702681
" EFFGEN_NO_DOTENV=1 skip the .env filesystem search entirely, so\n"
26712682
" only environment variables the orchestrator set\n"
@@ -2997,12 +3008,14 @@ def create_parser():
29973008
prompts_render = prompts_subparsers.add_parser('render', help='Non-interactive: render a prompt to stdout')
29983009
prompts_render.add_argument('prompt_name', metavar='name', help='Prompt name (e.g. research.literature_review.v1)')
29993010
prompts_render.add_argument('--input', dest='input_file', metavar='FILE',
3000-
help='JSON file with input variables (merged over fixture defaults)')
3011+
help="JSON file with input variables, validated against the prompt's "
3012+
"input_schema (see 'prompts show <name>'); omit to render the fixture")
30013013

30023014
prompts_run = prompts_subparsers.add_parser('run', help='Non-interactive: render + run through a model')
30033015
prompts_run.add_argument('prompt_name', metavar='name', help='Prompt name')
30043016
prompts_run.add_argument('--input', dest='input_file', metavar='FILE',
3005-
help='JSON file with input variables')
3017+
help="JSON file with input variables, validated against the prompt's "
3018+
"input_schema (see 'prompts show <name>'); omit to render the fixture")
30063019
prompts_run.add_argument('--model', required=True, help='Model identifier to run against')
30073020

30083021
# Load test command
@@ -4651,7 +4664,10 @@ def _handle_prompts_command(args, cli: "CLIInterface") -> int:
46514664
if RICH_AVAILABLE and cli.console:
46524665
from rich.table import Table
46534666
t = Table(title="Prompt Library", show_lines=False)
4654-
t.add_column("Name", style="cyan")
4667+
# Names must never be clipped — they're the id a user types back
4668+
# into `prompts show`/`run`/`render`. "fold" wraps onto extra
4669+
# lines instead of the default ellipsis truncation.
4670+
t.add_column("Name", style="cyan", overflow="fold")
46554671
t.add_column("Domain")
46564672
t.add_column("Variant")
46574673
t.add_column("Description")
@@ -4674,10 +4690,11 @@ def _handle_prompts_command(args, cli: "CLIInterface") -> int:
46744690
# ---- show ----
46754691
if cmd == 'show':
46764692
name = args.name
4693+
from effgen.cli.playground import _key_error_message, _resolve_prompt
46774694
try:
4678-
p = registry.get(name)
4679-
except KeyError:
4680-
cli.print_error(f"Prompt '{name}' not found.")
4695+
p, _resolved_name = _resolve_prompt(name)
4696+
except KeyError as exc:
4697+
cli.print_error(_key_error_message(exc, name))
46814698
return 1
46824699

46834700
cli.print_header(f"Prompt: {p.name}")

effgen/cli/chat.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,18 +590,45 @@ def _cmd_help(self) -> None:
590590
for cmd, desc in _SLASH_COMMANDS.items():
591591
print(f" {cmd:9s} {desc}")
592592

593+
def _resolve_swap_target(self, new_id: str) -> tuple[str | None, str]:
594+
"""Resolve a ``/model`` argument the same way a fresh ``chat -m <id>``
595+
(no ``--provider``) would.
596+
597+
A ``provider:model`` prefix pins that provider explicitly, mirroring
598+
startup resolution. A bare id clears any provider left over from a
599+
prior explicit ``--provider`` flag, so the loader's own auto-detection
600+
(catalog lookup / known-prefix matching) picks the right adapter
601+
instead of forcing the new id through the *previous* session's
602+
provider — which silently keeps the old adapter and fails every turn.
603+
"""
604+
if ":" in new_id:
605+
from effgen.models.registry import ProviderRegistry
606+
607+
prefix, rest = new_id.split(":", 1)
608+
try:
609+
known = ProviderRegistry.list_providers()
610+
except Exception: # noqa: BLE001
611+
known = []
612+
if prefix in known and rest:
613+
return prefix, rest
614+
return None, new_id
615+
593616
def _cmd_model(self, arg: str) -> None:
594617
if not arg:
595618
self.cli.print(f"Active model: {self.model_id}")
596619
self.cli.print("Swap with: /model <model-id> (e.g. /model gpt-5-nano)")
597620
return
598621
new_id = arg.split()[0]
599622
old_id = self.model_id
600-
self.model_id = new_id
623+
old_provider = self.provider
624+
new_provider, resolved_id = self._resolve_swap_target(new_id)
625+
self.model_id = resolved_id
626+
self.provider = new_provider
601627
try:
602628
self._rebuild()
603629
except Exception as e: # noqa: BLE001
604630
self.model_id = old_id
631+
self.provider = old_provider
605632
self.cli.print_error(f"Could not switch to '{new_id}': {e}")
606633
self._teach_model_error(e)
607634
# Restore the working agent.

effgen/cli/playground.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,24 @@ def _resolve_prompt(name: str) -> tuple[LibraryPrompt, str]:
116116
raise KeyError(f"Prompt '{name}' is ambiguous; use one of: {options}") from exc
117117

118118

119-
def _key_error_message(exc: KeyError) -> str:
120-
return str(exc.args[0]) if exc.args else str(exc)
119+
def _suggest_prompt_names(name: str, n: int = 3) -> list[str]:
120+
"""Closest registered prompt names to *name*, for a not-found error."""
121+
import difflib
122+
123+
try:
124+
candidates = [p.name for p in registry.all()]
125+
except Exception: # noqa: BLE001
126+
return []
127+
return difflib.get_close_matches(name, candidates, n=n, cutoff=0.5)
128+
129+
130+
def _key_error_message(exc: KeyError, name: str | None = None) -> str:
131+
msg = str(exc.args[0]) if exc.args else str(exc)
132+
if name and "not found in registry" in msg:
133+
suggestions = _suggest_prompt_names(name)
134+
if suggestions:
135+
msg += f". Did you mean: {', '.join(suggestions)}?"
136+
return msg
121137

122138

123139
# ---------------------------------------------------------------------------
@@ -200,12 +216,34 @@ def _run_prompt(rendered: str, model: str) -> str:
200216
# Non-interactive entry-points
201217
# ---------------------------------------------------------------------------
202218

219+
def _validate_or_report(p: LibraryPrompt, inputs: dict[str, Any]) -> bool:
220+
"""Validate a non-empty --input object against the prompt's input_schema.
221+
222+
Returns True when it's safe to render. An empty *inputs* dict means no
223+
--input was supplied (the fixture alone drives the render), so it skips
224+
validation. Reports one message per violation and leaves rendering to
225+
the caller to skip.
226+
"""
227+
if not inputs:
228+
return True
229+
errors = p.validate_input(inputs)
230+
if not errors:
231+
return True
232+
_print_err(f"Input for '{p.name}' does not match its input_schema:")
233+
for msg in errors:
234+
_print_err(f" {msg}")
235+
_print(f"Run 'effgen prompts show {p.name}' to see the schema and a valid fixture example.")
236+
return False
237+
238+
203239
def cmd_render(name: str, inputs: dict[str, Any]) -> int:
204240
"""Non-interactive render: print rendered prompt to stdout."""
205241
try:
206242
p, _resolved_name = _resolve_prompt(name)
207243
except KeyError as exc:
208-
_print_err(_key_error_message(exc))
244+
_print_err(_key_error_message(exc, name))
245+
return 1
246+
if not _validate_or_report(p, inputs):
209247
return 1
210248
try:
211249
merged = {**p.fixture, **inputs}
@@ -222,7 +260,9 @@ def cmd_run(name: str, inputs: dict[str, Any], model: str) -> int:
222260
try:
223261
p, _resolved_name = _resolve_prompt(name)
224262
except KeyError as exc:
225-
_print_err(_key_error_message(exc))
263+
_print_err(_key_error_message(exc, name))
264+
return 1
265+
if not _validate_or_report(p, inputs):
226266
return 1
227267
try:
228268
merged = {**p.fixture, **inputs}
@@ -348,7 +388,7 @@ def _cmd_select(self, rest: str) -> None:
348388
try:
349389
p, resolved_name = _resolve_prompt(name)
350390
except KeyError as exc:
351-
_print_err(f"{_key_error_message(exc)}. Try 'list' to see available prompts.")
391+
_print_err(f"{_key_error_message(exc, name)}. Try 'list' to see available prompts.")
352392
return
353393
self.session.prompt_name = resolved_name
354394
self.session.variables = dict(p.fixture) # seed with fixture defaults
@@ -486,7 +526,7 @@ def _cmd_show(self, rest: str) -> None:
486526
try:
487527
p, _resolved_name = _resolve_prompt(name)
488528
except KeyError as exc:
489-
_print_err(_key_error_message(exc))
529+
_print_err(_key_error_message(exc, name))
490530
return
491531
_print(f"\nName: {p.name}")
492532
_print(f"Domain: {p.domain}")

effgen/core/agent_generation.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
ModelNotFoundError,
2222
ModelRefusalError,
2323
classify_provider_error,
24+
simplify_embedded_provider_error,
2425
)
2526
from ..observability import get_logger as _get_obs_logger
2627
from ..utils.structured_logging import (
@@ -589,6 +590,10 @@ def _build_error_detail(self, exc: Exception, model: Any) -> dict[str, Any]:
589590
# the server's error envelope) doesn't get that prefix stacked twice.
590591
raw = getattr(exc, "message", None) or getattr(exc, "refusal_message", None)
591592
raw_message = raw if isinstance(raw, str) and raw else str(exc)
593+
# An adapter's actionable message can embed a raw SDK error body
594+
# ("Error code: 413 - {'error': {...}}") verbatim — collapse that to
595+
# its inner text so the response shows prose, not a dumped structure.
596+
raw_message = simplify_embedded_provider_error(raw_message)
592597
try:
593598
message = get_redactor().scrub(raw_message)
594599
except Exception: # redaction must never mask the underlying error

effgen/dashboard/static/app.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,20 @@
4646
if (txt) { txt.textContent = online ? "Connected" : "Offline"; }
4747
}
4848

49+
function showAuthBanner() {
50+
const el = $("auth-banner");
51+
if (!el) return;
52+
el.textContent = "Dashboard data requires authentication. Restart the server with "
53+
+ "EFFGEN_PUBLIC_DASHBOARD=1 for local viewing, or supply an API key "
54+
+ "(Authorization: Bearer <key> or X-API-Key: <key>).";
55+
el.hidden = false;
56+
}
57+
58+
function hideAuthBanner() {
59+
const el = $("auth-banner");
60+
if (el) el.hidden = true;
61+
}
62+
4963
// ------------------------------------------------------------------
5064
// Chart initialisation
5165
// ------------------------------------------------------------------
@@ -237,8 +251,14 @@
237251
async function fetchData() {
238252
try {
239253
const resp = await fetch("/dashboard/data.json", { cache: "no-store" });
254+
if (resp.status === 401) {
255+
setStatus(false);
256+
showAuthBanner();
257+
return;
258+
}
240259
if (!resp.ok) throw new Error("HTTP " + resp.status);
241260
const data = await resp.json();
261+
hideAuthBanner();
242262
setStatus(true);
243263
renderCards(data);
244264
renderSLO(data);

effgen/dashboard/static/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
</div>
2222
</header>
2323

24+
<div id="auth-banner" class="auth-banner" role="alert" hidden></div>
25+
2426
<main class="main">
2527

2628
<!-- Row 1: summary cards -->

effgen/dashboard/static/style.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ body {
6969
}
7070
#refresh-btn:hover { color: var(--accent2); }
7171

72+
/* ---- Auth banner ---- */
73+
.auth-banner {
74+
background: rgba(245, 158, 11, 0.12);
75+
border-bottom: 1px solid var(--warn);
76+
color: var(--text);
77+
padding: 0.6rem 1.5rem;
78+
font-size: 0.85rem;
79+
}
80+
.auth-banner[hidden] { display: none; }
81+
7282
/* ---- Layout ---- */
7383
.main {
7484
display: flex;

effgen/models/errors.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from __future__ import annotations
1111

12+
import ast
13+
import re
1214
from dataclasses import dataclass
1315

1416
# ---------------------------------------------------------------------------
@@ -562,3 +564,69 @@ def classify_provider_error(exc: Exception) -> ErrorClass:
562564
return _TRANSIENT
563565

564566
return _UNKNOWN
567+
568+
569+
_SDK_ERROR_BODY_RE = re.compile(r"Error code: \d+ - \{")
570+
571+
572+
def _balanced_brace_end(text: str, open_index: int) -> int | None:
573+
"""Return the index just past the ``{...}`` starting at *open_index*
574+
(which must be ``{``), respecting quoted strings. ``None`` if unbalanced."""
575+
depth = 0
576+
in_str = False
577+
str_char = ""
578+
i = open_index
579+
while i < len(text):
580+
ch = text[i]
581+
if in_str:
582+
if ch == "\\":
583+
i += 2
584+
continue
585+
if ch == str_char:
586+
in_str = False
587+
elif ch in ("'", '"'):
588+
in_str = True
589+
str_char = ch
590+
elif ch == "{":
591+
depth += 1
592+
elif ch == "}":
593+
depth -= 1
594+
if depth == 0:
595+
return i + 1
596+
i += 1
597+
return None
598+
599+
600+
def simplify_embedded_provider_error(message: str) -> str:
601+
"""Collapse an embedded raw SDK error body down to its inner message.
602+
603+
OpenAI-compatible SDKs (openai, groq, together, fireworks, ...) format an
604+
HTTP error's ``str()`` as ``Error code: N - {<python-dict-repr of the JSON
605+
body>}``. An adapter that embeds that raw text inside its own actionable
606+
message (e.g. "request too large for X: <raw body> — reduce the request
607+
...") ends up surfacing a dumped data structure instead of prose. This
608+
extracts the body's ``error.message`` field and substitutes it in place of
609+
the raw dict, leaving any surrounding text untouched. Returns *message*
610+
unchanged if no embedded body is found or it can't be parsed.
611+
"""
612+
match = _SDK_ERROR_BODY_RE.search(message)
613+
if not match:
614+
return message
615+
brace_start = match.end() - 1
616+
end = _balanced_brace_end(message, brace_start)
617+
if end is None:
618+
return message
619+
try:
620+
parsed = ast.literal_eval(message[brace_start:end])
621+
except (ValueError, SyntaxError, RecursionError):
622+
return message
623+
inner = None
624+
if isinstance(parsed, dict):
625+
err = parsed.get("error")
626+
if isinstance(err, dict) and isinstance(err.get("message"), str):
627+
inner = err["message"]
628+
elif isinstance(parsed.get("message"), str):
629+
inner = parsed["message"]
630+
if not inner:
631+
return message
632+
return message[: match.start()] + inner + message[end:]

effgen/models/groq_adapter.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,10 @@ def _do_generate(
509509

510510
failed_tool_call = _parse_failed_generation_tool_call(msg)
511511
if tools and "tool_use_failed" in msg_lower and failed_tool_call is not None:
512-
logger.warning(
512+
# Recovered, not actionable — the call still succeeds via
513+
# failed_generation. INFO (not WARNING) so a successful
514+
# turn emits no WARNING line by default; --verbose shows it.
515+
logger.info(
513516
"Groq returned tool_use_failed but included a parseable tool call; "
514517
"using failed_generation as structured tool call."
515518
)

effgen/models/openai_adapter.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,11 @@ def __init__(
149149
**kwargs,
150150
):
151151
if model_name not in OPENAI_MODELS:
152-
logger.warning(
152+
# Informational fallback, not an actionable warning — a valid new
153+
# or hot-swapped model id just isn't in the bundled catalog yet.
154+
# Surfaced at INFO so it shows with --verbose without making a
155+
# normal, successful run/chat turn look broken by default.
156+
logger.info(
153157
f"Model '{model_name}' is not in the OpenAI registry. "
154158
f"Using conservative defaults (context=128k, pricing fallback). "
155159
f"Call OpenAIAdapter.list_models() for registered ids."

0 commit comments

Comments
 (0)