langfuse-agent-cli 0.3.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.
- langfuse_agent_cli-0.3.0.dist-info/METADATA +235 -0
- langfuse_agent_cli-0.3.0.dist-info/RECORD +23 -0
- langfuse_agent_cli-0.3.0.dist-info/WHEEL +5 -0
- langfuse_agent_cli-0.3.0.dist-info/entry_points.txt +2 -0
- langfuse_agent_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
- langfuse_agent_cli-0.3.0.dist-info/top_level.txt +1 -0
- langfuse_cli/__init__.py +2 -0
- langfuse_cli/__main__.py +7 -0
- langfuse_cli/cli.py +197 -0
- langfuse_cli/client.py +196 -0
- langfuse_cli/commands/__init__.py +1 -0
- langfuse_cli/commands/auth.py +93 -0
- langfuse_cli/commands/dataset.py +37 -0
- langfuse_cli/commands/doctor.py +90 -0
- langfuse_cli/commands/help_map.py +167 -0
- langfuse_cli/commands/prompt.py +123 -0
- langfuse_cli/commands/score.py +24 -0
- langfuse_cli/commands/setup.py +101 -0
- langfuse_cli/commands/trace.py +83 -0
- langfuse_cli/config.py +187 -0
- langfuse_cli/formatters/__init__.py +15 -0
- langfuse_cli/formatters/agent.py +95 -0
- langfuse_cli/formatters/timeline.py +50 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Prompt management commands for Langfuse."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
|
|
10
|
+
from langfuse_cli.client import LangfuseClient
|
|
11
|
+
from langfuse_cli.config import resolve_from_args
|
|
12
|
+
from langfuse_cli.formatters.agent import print_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def compile_mustache(template: str, vars_dict: Dict[str, Any]) -> str:
|
|
16
|
+
"""Basic mustache variable substitution for {{var}}."""
|
|
17
|
+
result = template
|
|
18
|
+
for key, val in vars_dict.items():
|
|
19
|
+
pattern = r"\{\{\s*" + re.escape(key) + r"\s*\}\}"
|
|
20
|
+
# A function replacement inserts the value as-is; a string one would expand \1, \n and \U in it.
|
|
21
|
+
result = re.sub(pattern, lambda _match, value=str(val): value, result)
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compile_prompt(prompt_data: Any, vars_dict: Dict[str, Any]) -> Any:
|
|
26
|
+
if isinstance(prompt_data, str):
|
|
27
|
+
return compile_mustache(prompt_data, vars_dict)
|
|
28
|
+
elif isinstance(prompt_data, list):
|
|
29
|
+
# Chat messages: [{"role": "user", "content": "..."}]
|
|
30
|
+
out = []
|
|
31
|
+
for msg in prompt_data:
|
|
32
|
+
if isinstance(msg, dict) and "content" in msg and isinstance(msg["content"], str):
|
|
33
|
+
c = dict(msg)
|
|
34
|
+
c["content"] = compile_mustache(msg["content"], vars_dict)
|
|
35
|
+
out.append(c)
|
|
36
|
+
else:
|
|
37
|
+
out.append(msg)
|
|
38
|
+
return out
|
|
39
|
+
return prompt_data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def handle_prompt(args: argparse.Namespace) -> int:
|
|
43
|
+
client = LangfuseClient(resolve_from_args(args))
|
|
44
|
+
|
|
45
|
+
if args.prompt_action == "list":
|
|
46
|
+
prompts = client.list_prompts(limit=args.limit, tag=args.tag).get("data", [])
|
|
47
|
+
if args.output == "json":
|
|
48
|
+
print_json(args, prompts)
|
|
49
|
+
elif not prompts:
|
|
50
|
+
print("No prompts found.")
|
|
51
|
+
else:
|
|
52
|
+
print(f"Found {len(prompts)} prompts:")
|
|
53
|
+
for p in prompts:
|
|
54
|
+
versions = p.get("versions") or []
|
|
55
|
+
latest = max(versions) if versions else "?"
|
|
56
|
+
labels = ", ".join(p.get("labels", []))
|
|
57
|
+
labels_str = f" [labels: {labels}]" if labels else ""
|
|
58
|
+
print(f" - {p.get('name')} (v{latest}){labels_str} - type: {p.get('type', 'text')}")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
if args.prompt_action == "get":
|
|
62
|
+
vars_dict = None
|
|
63
|
+
if args.vars:
|
|
64
|
+
try:
|
|
65
|
+
vars_dict = json.loads(args.vars)
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
print(f"Error: --vars is not valid JSON: {e}", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
if not isinstance(vars_dict, dict):
|
|
70
|
+
print('Error: --vars must be a JSON object, for example \'{"user_name": "Alice"}\'', file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
prompt = client.get_prompt(name=args.name, version=args.version, label=args.label)
|
|
73
|
+
if vars_dict is not None:
|
|
74
|
+
prompt["compiled_prompt"] = compile_prompt(prompt.get("prompt"), vars_dict)
|
|
75
|
+
prompt["applied_vars"] = vars_dict
|
|
76
|
+
|
|
77
|
+
if args.output == "json":
|
|
78
|
+
print_json(args, prompt)
|
|
79
|
+
else:
|
|
80
|
+
print(f"Prompt: {prompt.get('name')} (v{prompt.get('version')})")
|
|
81
|
+
print(f"Type: {prompt.get('type', 'text')}")
|
|
82
|
+
if prompt.get("labels"):
|
|
83
|
+
print(f"Labels: {', '.join(prompt['labels'])}")
|
|
84
|
+
if prompt.get("tags"):
|
|
85
|
+
print(f"Tags: {', '.join(prompt['tags'])}")
|
|
86
|
+
print("-" * 40)
|
|
87
|
+
display_content = prompt.get("compiled_prompt") or prompt.get("prompt")
|
|
88
|
+
if isinstance(display_content, (dict, list)):
|
|
89
|
+
print(json.dumps(display_content, indent=2))
|
|
90
|
+
else:
|
|
91
|
+
print(display_content)
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
# push
|
|
95
|
+
if args.file:
|
|
96
|
+
with open(args.file, "r", encoding="utf-8") as f:
|
|
97
|
+
content = f.read()
|
|
98
|
+
if args.type == "chat":
|
|
99
|
+
try:
|
|
100
|
+
prompt_content = json.loads(content)
|
|
101
|
+
except ValueError as e:
|
|
102
|
+
print(f"Error: --file must hold a JSON list of messages for --type chat: {e}", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
else:
|
|
105
|
+
prompt_content = content # a text prompt is sent as written, even when it looks like JSON
|
|
106
|
+
elif args.text:
|
|
107
|
+
prompt_content = args.text
|
|
108
|
+
else:
|
|
109
|
+
print("Error: Specify either --file <path> or --text '<prompt string>'", file=sys.stderr)
|
|
110
|
+
return 1
|
|
111
|
+
|
|
112
|
+
created = client.create_prompt(
|
|
113
|
+
name=args.name,
|
|
114
|
+
prompt_content=prompt_content,
|
|
115
|
+
prompt_type=args.type,
|
|
116
|
+
labels=[label.strip() for label in args.labels.split(",")] if args.labels else None,
|
|
117
|
+
tags=[tag.strip() for tag in args.tags.split(",")] if args.tags else None,
|
|
118
|
+
)
|
|
119
|
+
if args.output == "json":
|
|
120
|
+
print_json(args, created)
|
|
121
|
+
else:
|
|
122
|
+
print(f"✓ Prompt '{args.name}' (v{created.get('version', 'new')}) published successfully.")
|
|
123
|
+
return 0
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Score and evaluation metric commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
from langfuse_cli.client import LangfuseClient
|
|
7
|
+
from langfuse_cli.config import resolve_from_args
|
|
8
|
+
from langfuse_cli.formatters.agent import print_json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def handle_score(args: argparse.Namespace) -> int:
|
|
12
|
+
client = LangfuseClient(resolve_from_args(args))
|
|
13
|
+
res = client.create_score(
|
|
14
|
+
trace_id=args.trace_id,
|
|
15
|
+
name=args.name,
|
|
16
|
+
value=args.value,
|
|
17
|
+
comment=args.comment,
|
|
18
|
+
observation_id=args.observation_id,
|
|
19
|
+
)
|
|
20
|
+
if args.output == "json":
|
|
21
|
+
print_json(args, res)
|
|
22
|
+
else:
|
|
23
|
+
print(f"✓ Score '{args.name}'={args.value} recorded for trace '{args.trace_id}'.")
|
|
24
|
+
return 0
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""One-step setup: credentials, the agent skill, then doctor."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from langfuse_cli.client import LangfuseAPIError, LangfuseClient
|
|
11
|
+
from langfuse_cli.commands.auth import login
|
|
12
|
+
from langfuse_cli.commands.doctor import SKILL_NAME, SKILL_SOURCE, handle_doctor
|
|
13
|
+
from langfuse_cli.config import ConfigError, ConfigManager, resolve_from_args
|
|
14
|
+
|
|
15
|
+
INSTALL_LATER = f" To install it later: npx skills@1 add {SKILL_SOURCE} -g"
|
|
16
|
+
SOURCE_NAMES = {"env": "the LANGFUSE_* environment variables", "env-file": "the --env-file file",
|
|
17
|
+
"flags": "--public-key and --secret-key"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _credentials(args: argparse.Namespace, interactive: bool) -> None:
|
|
21
|
+
try:
|
|
22
|
+
prof = resolve_from_args(args)
|
|
23
|
+
except ConfigError as e: # doctor reports it again; the skill step still runs
|
|
24
|
+
print(f" ✗ {e}")
|
|
25
|
+
return
|
|
26
|
+
rejected = True # missing keys count as rejected
|
|
27
|
+
if prof.is_complete():
|
|
28
|
+
try:
|
|
29
|
+
projects = LangfuseClient(prof).get_project().get("data") or []
|
|
30
|
+
except LangfuseAPIError as e:
|
|
31
|
+
print(f" ✗ {e}")
|
|
32
|
+
rejected = e.status_code in (401, 403)
|
|
33
|
+
else:
|
|
34
|
+
names = ", ".join(p.get("name", "?") for p in projects) or "(none)"
|
|
35
|
+
print(f" ✓ Using {prof.source} credentials for {prof.host} (project: {names})")
|
|
36
|
+
return
|
|
37
|
+
if not rejected: # new keys would not help; asking for them invites overwriting good ones
|
|
38
|
+
print(" – Login skipped: the keys were not rejected; check the host and that the server is up.")
|
|
39
|
+
elif prof.source != "profile": # these sources outrank saved profiles, so a login here would change nothing
|
|
40
|
+
print(f" – Login skipped: these keys come from {SOURCE_NAMES[prof.source]}, which take precedence over "
|
|
41
|
+
"saved profiles. Fix or unset them, then run lf setup again.")
|
|
42
|
+
elif not interactive:
|
|
43
|
+
print(" – Login skipped: lf setup never asks for keys with --yes or without a terminal. "
|
|
44
|
+
"Ask the user to run 'lf setup' in their own terminal.")
|
|
45
|
+
else:
|
|
46
|
+
host = input(f" Langfuse host [{prof.host}]: ").strip() or prof.host
|
|
47
|
+
login(ConfigManager(), prof.name, host, None, None, set_default=True) # keys that failed are asked for again
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _install_skill(interactive: bool) -> bool:
|
|
51
|
+
"""Install the skill with the skills CLI; False when it could not be installed (declining is not a failure)."""
|
|
52
|
+
npx = shutil.which("npx")
|
|
53
|
+
if npx is None:
|
|
54
|
+
print(" ✗ npx not found; the skills CLI needs Node.js 22.20 or later.")
|
|
55
|
+
print(INSTALL_LATER)
|
|
56
|
+
return False
|
|
57
|
+
# On Windows, which() searches the current folder first; never run an npx that a project folder supplies.
|
|
58
|
+
if os.path.normcase(os.path.realpath(os.path.dirname(npx))) == os.path.normcase(os.path.realpath(os.getcwd())):
|
|
59
|
+
print(f" ✗ Not running {npx}: it is in the current folder. Run lf setup from another folder.")
|
|
60
|
+
print(INSTALL_LATER)
|
|
61
|
+
return False
|
|
62
|
+
if interactive and input(f" Install the {SKILL_NAME} skill for your coding agents? [Y/n] ").strip().lower() in (
|
|
63
|
+
"n", "no"):
|
|
64
|
+
print(INSTALL_LATER)
|
|
65
|
+
return True
|
|
66
|
+
# npx --yes skips npm's install prompt. The skills CLI's own -y skips its questions: without it, it asks which
|
|
67
|
+
# agents to use when it finds several; with it, it takes the agents it finds, or every agent it supports when it
|
|
68
|
+
# finds none. Run from inside a coding agent, it targets that agent either way.
|
|
69
|
+
cmd = [npx, "--yes", "skills@1", "add", SKILL_SOURCE, "-g"]
|
|
70
|
+
if not interactive:
|
|
71
|
+
cmd.append("-y")
|
|
72
|
+
# No LANGFUSE_* keys for npx and the install scripts it runs; git must fail rather than wait for a password.
|
|
73
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("LANGFUSE_")}
|
|
74
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
75
|
+
print(f" Running: npx {' '.join(cmd[1:])}", flush=True)
|
|
76
|
+
try:
|
|
77
|
+
# Unattended runs get no stdin and 90 s, so they end inside an agent's usual 2-minute command limit.
|
|
78
|
+
result = subprocess.run(cmd, env=env, stdin=None if interactive else subprocess.DEVNULL,
|
|
79
|
+
timeout=None if interactive else 90)
|
|
80
|
+
reason = f"npx exited with code {result.returncode}" if result.returncode else ""
|
|
81
|
+
except (subprocess.TimeoutExpired, OSError) as e: # OSError: npx was found but could not be started
|
|
82
|
+
reason = str(e)
|
|
83
|
+
if reason:
|
|
84
|
+
print(f" ✗ The skill was not installed: {reason}")
|
|
85
|
+
print(INSTALL_LATER)
|
|
86
|
+
return not reason
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def handle_setup(args: argparse.Namespace) -> int:
|
|
90
|
+
interactive = not args.yes and sys.stdin is not None and sys.stdin.isatty()
|
|
91
|
+
print("\nLangfuse Agent CLI Setup")
|
|
92
|
+
print("\n1. Credentials")
|
|
93
|
+
_credentials(args, interactive)
|
|
94
|
+
print("\n2. Agent Skill")
|
|
95
|
+
installed = _install_skill(interactive)
|
|
96
|
+
print("\n3. Check")
|
|
97
|
+
code = handle_doctor(args)
|
|
98
|
+
if not installed:
|
|
99
|
+
print(" ✗ lf setup did not install the skill (see step 2); any copy listed above is from an earlier install.")
|
|
100
|
+
return 1
|
|
101
|
+
return code
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Trace commands, read through the observations v2 API (the only trace read path on Langfuse v4)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Dict, Iterable, List
|
|
7
|
+
|
|
8
|
+
from langfuse_cli.client import LangfuseClient
|
|
9
|
+
from langfuse_cli.config import resolve_from_args
|
|
10
|
+
from langfuse_cli.formatters.agent import print_json
|
|
11
|
+
from langfuse_cli.formatters.timeline import render_trace_timeline
|
|
12
|
+
|
|
13
|
+
# Field groups of GET /api/public/v2/observations. "metrics" carries latency; the spec puts it there.
|
|
14
|
+
# Langfuse 4.70 returns latency null instead of 0 for zero-length observations when "metrics" is requested.
|
|
15
|
+
ROOT_FIELDS = "core,basic,metrics" # a trace is listed as its root observation
|
|
16
|
+
TREE_FIELDS = "core,basic,time,model,usage,metrics" # leaves out io (--io adds it), metadata, prompt, trace_context
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def group_errors(observations: Iterable[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]:
|
|
20
|
+
"""ERROR observations grouped by trace in first-seen order, at most `limit` traces."""
|
|
21
|
+
groups: Dict[Any, Dict[str, Any]] = {}
|
|
22
|
+
for obs in observations:
|
|
23
|
+
trace_id = obs.get("traceId")
|
|
24
|
+
if trace_id not in groups:
|
|
25
|
+
if len(groups) == limit:
|
|
26
|
+
break # stops the lazy page iterator too
|
|
27
|
+
groups[trace_id] = {"traceId": trace_id, "sessionId": obs.get("sessionId"),
|
|
28
|
+
"userId": obs.get("userId"), "errors": []}
|
|
29
|
+
groups[trace_id]["errors"].append({k: obs.get(k) for k in ("id", "name", "type", "statusMessage", "startTime")})
|
|
30
|
+
return list(groups.values())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _trace_line(row: Dict[str, Any]) -> str:
|
|
34
|
+
latency = f" ({float(row['latency']):.2f}s)" if row.get("latency") is not None else ""
|
|
35
|
+
return f" - [{row.get('traceId')}] {row.get('name') or 'unnamed'}{latency} @ {row.get('startTime')}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _emit(args: argparse.Namespace, rows: List[Dict[str, Any]], lines: List[str], empty: str) -> int:
|
|
39
|
+
if args.output == "json":
|
|
40
|
+
print_json(args, rows)
|
|
41
|
+
else:
|
|
42
|
+
print("\n".join(lines) if lines else empty)
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _by_start(obs: Dict[str, Any]) -> str:
|
|
47
|
+
return obs.get("startTime") or ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def handle_trace(args: argparse.Namespace) -> int:
|
|
51
|
+
client = LangfuseClient(resolve_from_args(args))
|
|
52
|
+
|
|
53
|
+
if args.trace_action == "list":
|
|
54
|
+
filters = {"sessionId": args.session_id, "userId": args.user_id,
|
|
55
|
+
"fromStartTime": args.from_ts, "toStartTime": args.to_ts}
|
|
56
|
+
if args.status == "error":
|
|
57
|
+
# shortcut: scans at most 5000 error rows; raise the cap if busy projects need longer windows
|
|
58
|
+
errors = client.iter_observations({**filters, "level": "ERROR", "fields": ROOT_FIELDS}, max_items=5000)
|
|
59
|
+
rows = group_errors(errors, args.limit)
|
|
60
|
+
lines = [f" - [{g['traceId']}] {len(g['errors'])} error(s): "
|
|
61
|
+
+ "; ".join(f"{e['name']}: {e['statusMessage']}" for e in g["errors"]) for g in rows]
|
|
62
|
+
return _emit(args, rows, lines, "No traces with errors found.")
|
|
63
|
+
rows = client.list_observations(**filters, isRootObservation=True, fields=ROOT_FIELDS, limit=args.limit)
|
|
64
|
+
rows = rows.get("data", [])
|
|
65
|
+
return _emit(args, rows, [_trace_line(r) for r in rows], "No traces found.")
|
|
66
|
+
|
|
67
|
+
if args.trace_action == "get":
|
|
68
|
+
fields = TREE_FIELDS + (",io" if args.io else "")
|
|
69
|
+
observations = sorted(client.iter_observations({"traceId": args.trace_id, "fields": fields}), key=_by_start)
|
|
70
|
+
if not observations:
|
|
71
|
+
print(f"Error: trace {args.trace_id} not found", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
if args.timeline or args.output == "text":
|
|
74
|
+
print(render_trace_timeline(args.trace_id, observations))
|
|
75
|
+
else:
|
|
76
|
+
print_json(args, observations)
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
# session: its traces, oldest first, so a conversation reads top to bottom
|
|
80
|
+
# shortcut: keeps the newest 1000 traces (the API returns newest first); page further if sessions grow
|
|
81
|
+
params = {"sessionId": args.session_id, "isRootObservation": True, "fields": ROOT_FIELDS}
|
|
82
|
+
rows = sorted(client.iter_observations(params, max_items=1000), key=_by_start)
|
|
83
|
+
return _emit(args, rows, [_trace_line(r) for r in rows], f"No traces in session {args.session_id}.")
|
langfuse_cli/config.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Configuration and profile management for Langfuse CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "langfuse"
|
|
13
|
+
DEFAULT_PROFILES_FILE = DEFAULT_CONFIG_DIR / "profiles.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(Exception):
|
|
17
|
+
"""Credentials or the profiles file cannot be used as given."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ProfileConfig:
|
|
22
|
+
name: str
|
|
23
|
+
host: str
|
|
24
|
+
public_key: str
|
|
25
|
+
secret_key: str
|
|
26
|
+
source: str = "" # "profile", "env" or "env-file"; "flags" when both keys come from flags
|
|
27
|
+
|
|
28
|
+
def is_complete(self) -> bool:
|
|
29
|
+
return bool(self.host and self.public_key and self.secret_key)
|
|
30
|
+
|
|
31
|
+
def masked_public_key(self) -> str:
|
|
32
|
+
if not self.public_key:
|
|
33
|
+
return "(not set)"
|
|
34
|
+
if len(self.public_key) <= 8:
|
|
35
|
+
return "******"
|
|
36
|
+
return f"{self.public_key[:6]}...{self.public_key[-4:]}"
|
|
37
|
+
|
|
38
|
+
def masked_secret_key(self) -> str:
|
|
39
|
+
if not self.secret_key:
|
|
40
|
+
return "(not set)"
|
|
41
|
+
if len(self.secret_key) <= 8:
|
|
42
|
+
return "******"
|
|
43
|
+
return f"{self.secret_key[:6]}...{self.secret_key[-4:]}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConfigManager:
|
|
47
|
+
"""Manages persistent profiles and active session configurations."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
50
|
+
self.config_path = config_path or DEFAULT_PROFILES_FILE
|
|
51
|
+
self._cache: Optional[Dict[str, Any]] = None
|
|
52
|
+
|
|
53
|
+
def _load_raw(self) -> Dict[str, Any]:
|
|
54
|
+
if self._cache is not None:
|
|
55
|
+
return self._cache
|
|
56
|
+
data: Any = {}
|
|
57
|
+
if self.config_path.exists():
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(self.config_path.read_text(encoding="utf-8"))
|
|
60
|
+
except ValueError as e:
|
|
61
|
+
# Refuse to continue: the next save would silently drop every saved profile.
|
|
62
|
+
raise ConfigError(f"{self.config_path} is not valid JSON; fix or delete it") from e
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
raise ConfigError(f"{self.config_path} is not a profiles file; fix or delete it")
|
|
65
|
+
data.setdefault("current_profile", "default")
|
|
66
|
+
data.setdefault("profiles", {})
|
|
67
|
+
self._cache = data
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
def _save_raw(self, data: Dict[str, Any]) -> None:
|
|
71
|
+
# The file holds secret keys: create directory and file private from the start, then swap it in.
|
|
72
|
+
self.config_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
73
|
+
fd, tmp = tempfile.mkstemp(dir=self.config_path.parent, prefix=".profiles-")
|
|
74
|
+
try:
|
|
75
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
76
|
+
json.dump(data, f, indent=2)
|
|
77
|
+
os.replace(tmp, self.config_path)
|
|
78
|
+
except BaseException:
|
|
79
|
+
os.unlink(tmp)
|
|
80
|
+
raise
|
|
81
|
+
self._cache = data
|
|
82
|
+
|
|
83
|
+
def list_profiles(self) -> Dict[str, ProfileConfig]:
|
|
84
|
+
data = self._load_raw()
|
|
85
|
+
profiles = {}
|
|
86
|
+
for name, p in data.get("profiles", {}).items():
|
|
87
|
+
profiles[name] = ProfileConfig(
|
|
88
|
+
name=name,
|
|
89
|
+
host=p.get("host", ""),
|
|
90
|
+
public_key=p.get("public_key", ""),
|
|
91
|
+
secret_key=p.get("secret_key", ""),
|
|
92
|
+
source="profile",
|
|
93
|
+
)
|
|
94
|
+
return profiles
|
|
95
|
+
|
|
96
|
+
def get_current_profile_name(self) -> str:
|
|
97
|
+
return self._load_raw().get("current_profile", "default")
|
|
98
|
+
|
|
99
|
+
def set_current_profile(self, name: str) -> None:
|
|
100
|
+
data = self._load_raw()
|
|
101
|
+
data["current_profile"] = name
|
|
102
|
+
self._save_raw(data)
|
|
103
|
+
|
|
104
|
+
def save_profile(
|
|
105
|
+
self,
|
|
106
|
+
name: str,
|
|
107
|
+
host: str,
|
|
108
|
+
public_key: str,
|
|
109
|
+
secret_key: str,
|
|
110
|
+
set_default: bool = False,
|
|
111
|
+
) -> None:
|
|
112
|
+
data = self._load_raw()
|
|
113
|
+
clean_host = host.rstrip("/")
|
|
114
|
+
data["profiles"][name] = {
|
|
115
|
+
"host": clean_host,
|
|
116
|
+
"public_key": public_key,
|
|
117
|
+
"secret_key": secret_key,
|
|
118
|
+
}
|
|
119
|
+
if set_default or len(data["profiles"]) == 1:
|
|
120
|
+
data["current_profile"] = name
|
|
121
|
+
self._save_raw(data)
|
|
122
|
+
|
|
123
|
+
def resolve_profile(
|
|
124
|
+
self,
|
|
125
|
+
name: Optional[str] = None,
|
|
126
|
+
host_override: Optional[str] = None,
|
|
127
|
+
pk_override: Optional[str] = None,
|
|
128
|
+
sk_override: Optional[str] = None,
|
|
129
|
+
env_file: Optional[str] = None,
|
|
130
|
+
) -> ProfileConfig:
|
|
131
|
+
"""Take host and keys from one source, then apply explicit flag overrides.
|
|
132
|
+
|
|
133
|
+
Sources, first match wins: --env-file, --profile / LANGFUSE_PROFILE, the environment (when
|
|
134
|
+
LANGFUSE_PUBLIC_KEY or LANGFUSE_SECRET_KEY is set), the saved current profile. Sources are
|
|
135
|
+
never mixed, so a host found in one place (a cloned repo's .env, say) cannot receive keys that
|
|
136
|
+
came from another.
|
|
137
|
+
"""
|
|
138
|
+
explicit = name or os.environ.get("LANGFUSE_PROFILE")
|
|
139
|
+
if env_file:
|
|
140
|
+
base = self._from_vars(self._parse_dotenv(env_file), "env-file")
|
|
141
|
+
elif explicit:
|
|
142
|
+
base = self.list_profiles().get(explicit)
|
|
143
|
+
if base is None:
|
|
144
|
+
raise ConfigError(f"profile '{explicit}' not found; see 'lf auth list'")
|
|
145
|
+
elif os.environ.get("LANGFUSE_PUBLIC_KEY") or os.environ.get("LANGFUSE_SECRET_KEY"):
|
|
146
|
+
base = self._from_vars(os.environ, "env")
|
|
147
|
+
else:
|
|
148
|
+
current = self.get_current_profile_name()
|
|
149
|
+
base = self.list_profiles().get(current) or ProfileConfig(current, "", "", "", "profile")
|
|
150
|
+
|
|
151
|
+
return ProfileConfig(
|
|
152
|
+
name=base.name,
|
|
153
|
+
host=(host_override or base.host or "https://cloud.langfuse.com").rstrip("/"),
|
|
154
|
+
public_key=pk_override or base.public_key,
|
|
155
|
+
secret_key=sk_override or base.secret_key,
|
|
156
|
+
source="flags" if pk_override and sk_override else base.source,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _from_vars(values: Any, source: str) -> ProfileConfig:
|
|
161
|
+
return ProfileConfig(
|
|
162
|
+
name="",
|
|
163
|
+
host=values.get("LANGFUSE_BASE_URL") or values.get("LANGFUSE_HOST") or "",
|
|
164
|
+
public_key=values.get("LANGFUSE_PUBLIC_KEY", ""),
|
|
165
|
+
secret_key=values.get("LANGFUSE_SECRET_KEY", ""),
|
|
166
|
+
source=source,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _parse_dotenv(path: str) -> Dict[str, str]:
|
|
171
|
+
try:
|
|
172
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
173
|
+
except OSError as e:
|
|
174
|
+
raise ConfigError(f"cannot read env file {path}: {e}") from e
|
|
175
|
+
res = {}
|
|
176
|
+
for line in lines:
|
|
177
|
+
line = line.strip()
|
|
178
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
179
|
+
continue
|
|
180
|
+
k, v = line.split("=", 1)
|
|
181
|
+
res[k.strip()] = v.strip().strip("'\"")
|
|
182
|
+
return res
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def resolve_from_args(args: argparse.Namespace) -> ProfileConfig:
|
|
186
|
+
"""Credentials for one command, from its parsed common options."""
|
|
187
|
+
return ConfigManager().resolve_profile(args.profile, args.host, args.public_key, args.secret_key, args.env_file)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Formatters for human terminal and agent-compact views."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f\ud800-\udfff]")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def clean(text: str) -> str:
|
|
8
|
+
"""Escape terminal control characters (except newline and tab) and lone UTF-16 surrogates.
|
|
9
|
+
|
|
10
|
+
Trace names, inputs and error messages come from end users of the traced app; printed raw,
|
|
11
|
+
an escape sequence in them could rewrite the terminal. A lone surrogate (text cut inside an
|
|
12
|
+
emoji) cannot be encoded as UTF-8 at all.
|
|
13
|
+
"""
|
|
14
|
+
# \uXXXX rather than \xXX: it is also a valid JSON escape, so JSON output stays parseable and lossless.
|
|
15
|
+
return _CONTROL_CHARS.sub(lambda m: f"\\u{ord(m.group()):04x}", text)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Token-efficient output formatters for AI coding assistants."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from langfuse_cli.formatters import clean
|
|
11
|
+
|
|
12
|
+
SUMMARY_THRESHOLD_BYTES = 16 * 1024
|
|
13
|
+
SUMMARY_HINT = ("The JSON output is larger than 16 KB, so it was written to outputFile. Read only the parts you need, "
|
|
14
|
+
"for example with jq, or narrow the command with --agent-fields.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_field_path(data: Any, path: str) -> Any:
|
|
18
|
+
"""Value at a dotted path like 'usageDetails.input'; a list on the way maps the rest over its items."""
|
|
19
|
+
if isinstance(data, list):
|
|
20
|
+
return [extract_field_path(item, path) for item in data]
|
|
21
|
+
if not isinstance(data, dict):
|
|
22
|
+
return None
|
|
23
|
+
head, _, rest = path.partition(".")
|
|
24
|
+
value = data.get(head)
|
|
25
|
+
return extract_field_path(value, rest) if rest else value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def filter_dict_fields(item: Dict[str, Any], fields: List[str]) -> Dict[str, Any]:
|
|
29
|
+
out: Dict[str, Any] = {}
|
|
30
|
+
for f in fields:
|
|
31
|
+
val = extract_field_path(item, f)
|
|
32
|
+
if val is not None:
|
|
33
|
+
out[f] = val
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def prune(data: Any) -> Any:
|
|
38
|
+
"""Drop None, "", [] and {} values at every level; this is what --compact does."""
|
|
39
|
+
empty = (None, "", [], {})
|
|
40
|
+
if isinstance(data, dict):
|
|
41
|
+
pruned = {k: prune(v) for k, v in data.items()}
|
|
42
|
+
return {k: v for k, v in pruned.items() if v not in empty}
|
|
43
|
+
if isinstance(data, list):
|
|
44
|
+
return [v for v in map(prune, data) if v not in empty]
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_agent_output(
|
|
49
|
+
data: Any,
|
|
50
|
+
compact: bool = False,
|
|
51
|
+
agent_fields: Optional[str] = None,
|
|
52
|
+
summary: bool = False,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""JSON for agents: optional field projection (applied to each list item), then optional pruning.
|
|
55
|
+
|
|
56
|
+
With `summary`, output over SUMMARY_THRESHOLD_BYTES goes to a file and a short envelope is returned instead.
|
|
57
|
+
"""
|
|
58
|
+
if agent_fields:
|
|
59
|
+
fields = [f.strip() for f in agent_fields.split(",") if f.strip()]
|
|
60
|
+
if isinstance(data, list):
|
|
61
|
+
data = [filter_dict_fields(d, fields) for d in data if isinstance(d, dict)]
|
|
62
|
+
elif isinstance(data, dict):
|
|
63
|
+
data = filter_dict_fields(data, fields)
|
|
64
|
+
if compact:
|
|
65
|
+
# Prune inside each row but keep the rows: dropping emptied rows would print [] for real results.
|
|
66
|
+
data = [prune(row) for row in data] if isinstance(data, list) else prune(data)
|
|
67
|
+
text = json.dumps(data, indent=2, ensure_ascii=False)
|
|
68
|
+
if summary and len(text.encode("utf-8", "surrogatepass")) > SUMMARY_THRESHOLD_BYTES:
|
|
69
|
+
return json.dumps(_write_summary(data, text), indent=2)
|
|
70
|
+
return text
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _write_summary(data: Any, text: str) -> Dict[str, Any]:
|
|
74
|
+
"""Write `text` to a new private file and describe it."""
|
|
75
|
+
content = (clean(text) + "\n").encode("utf-8") # the file gets the same escaping as the terminal
|
|
76
|
+
fd, path = tempfile.mkstemp(prefix="lf-", suffix=".json") # mode 0600; kept, the caller reads it after lf exits
|
|
77
|
+
try:
|
|
78
|
+
with os.fdopen(fd, "wb") as f:
|
|
79
|
+
f.write(content)
|
|
80
|
+
except BaseException:
|
|
81
|
+
os.unlink(path) # no half-written file left behind
|
|
82
|
+
raise
|
|
83
|
+
envelope: Dict[str, Any] = {"summary": True, "outputFile": path, "bytes": len(content)}
|
|
84
|
+
if isinstance(data, list):
|
|
85
|
+
envelope["rows"] = len(data)
|
|
86
|
+
envelope["rowKeys"] = list(dict.fromkeys(k for row in data if isinstance(row, dict) for k in row))
|
|
87
|
+
elif isinstance(data, dict):
|
|
88
|
+
envelope["keys"] = list(data)
|
|
89
|
+
envelope["hint"] = SUMMARY_HINT
|
|
90
|
+
return envelope
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def print_json(args: argparse.Namespace, data: Any) -> None:
|
|
94
|
+
"""Print a data command's JSON result, honouring --agent-fields, --compact and --output-summary."""
|
|
95
|
+
print(format_agent_output(data, compact=args.compact, agent_fields=args.agent_fields, summary=args.output_summary))
|