applied-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ import json
2
+ from typing import Any, Optional
3
+
4
+ import typer
5
+
6
+ from applied_cli.commands._parsers import parse_json_dict, validate_uuid
7
+ from applied_cli.error_reporting import render_api_error
8
+ from applied_cli.http import APIError, simulate_agent_conversation
9
+ from applied_cli.runtime import resolve_runtime
10
+
11
+ app = typer.Typer(
12
+ help="Generate simulated conversations (including backdated timestamps)."
13
+ )
14
+
15
+
16
+ @app.command(
17
+ "run",
18
+ help=(
19
+ "Simulate conversations for an agent with sensible defaults. Example: "
20
+ "applied-cli simulate run --agent-id <uuid> --count 10 "
21
+ "--created-at-from 2026-01-26T00:00:00Z --created-at-to 2026-02-26T23:59:59Z "
22
+ "--is-test false --conversation-type web_chat"
23
+ ),
24
+ )
25
+ @app.command(
26
+ "conversations",
27
+ hidden=True,
28
+ help="Alias for `simulate run`.",
29
+ )
30
+ def conversations_cmd(
31
+ agent_id: str = typer.Option(
32
+ ...,
33
+ "--agent-id",
34
+ "--agent",
35
+ "--id",
36
+ help="Target agent UUID.",
37
+ ),
38
+ count: int = typer.Option(
39
+ 1,
40
+ "--count",
41
+ help="Number of conversations to simulate.",
42
+ ),
43
+ max_turns: int = typer.Option(
44
+ 6,
45
+ "--max-turns",
46
+ help="Maximum turns per simulated conversation (1-10).",
47
+ ),
48
+ agent_subtype: Optional[str] = typer.Option(
49
+ None,
50
+ "--agent-subtype",
51
+ help="Optional agent subtype forwarded to simulate endpoint.",
52
+ ),
53
+ conversation_type: Optional[str] = typer.Option(
54
+ None,
55
+ "--conversation-type",
56
+ help="Optional conversation type (e.g. web_chat, email, sms).",
57
+ ),
58
+ direction: Optional[str] = typer.Option(
59
+ None,
60
+ "--direction",
61
+ help="Optional direction (e.g. inbound, outbound).",
62
+ ),
63
+ is_test: bool = typer.Option(
64
+ True,
65
+ "--is-test/--no-is-test",
66
+ help="Mark simulated conversations as test data.",
67
+ ),
68
+ is_historical: bool = typer.Option(
69
+ True,
70
+ "--is-historical/--no-is-historical",
71
+ help="Mark simulated conversations as historical.",
72
+ ),
73
+ remote_platform: Optional[str] = typer.Option(
74
+ None,
75
+ "--remote-platform",
76
+ help="Optional remote platform tag (for example: instagram).",
77
+ ),
78
+ created_at: Optional[str] = typer.Option(
79
+ None,
80
+ "--created-at",
81
+ help="Explicit timestamp (ISO-8601) for each simulation run.",
82
+ ),
83
+ created_at_from: Optional[str] = typer.Option(
84
+ None,
85
+ "--created-at-from",
86
+ help="Start timestamp (ISO-8601) for random timestamp simulation.",
87
+ ),
88
+ created_at_to: Optional[str] = typer.Option(
89
+ None,
90
+ "--created-at-to",
91
+ help="End timestamp (ISO-8601) for random timestamp simulation.",
92
+ ),
93
+ created_at_distribution: str = typer.Option(
94
+ "daily_peaks",
95
+ "--created-at-distribution",
96
+ help="Timestamp distribution: flat, daily_peaks, weekly_peaks.",
97
+ ),
98
+ label_id: Optional[str] = typer.Option(
99
+ None,
100
+ "--label-id",
101
+ help="Optional topic UUID.",
102
+ ),
103
+ sublabel_id: Optional[str] = typer.Option(
104
+ None,
105
+ "--sublabel-id",
106
+ help="Optional intent UUID.",
107
+ ),
108
+ metadata_json: Optional[str] = typer.Option(
109
+ None,
110
+ "--metadata-json",
111
+ help='Optional conversation metadata JSON object. Example: \'{"source":"cli"}\'',
112
+ ),
113
+ simulate_experiments: bool = typer.Option(
114
+ False,
115
+ "--simulate-experiments/--no-simulate-experiments",
116
+ help="Enable experiment traffic simulation fields.",
117
+ ),
118
+ experiment_id: Optional[str] = typer.Option(
119
+ None,
120
+ "--experiment-id",
121
+ help="Optional experiment UUID when simulating experiments.",
122
+ ),
123
+ traffic_rule_id: Optional[str] = typer.Option(
124
+ None,
125
+ "--traffic-rule-id",
126
+ help="Optional traffic rule UUID when simulating experiments.",
127
+ ),
128
+ populate_workforce_actions: bool = typer.Option(
129
+ False,
130
+ "--populate-workforce-actions/--no-populate-workforce-actions",
131
+ help="Emit workforce-style actions/metrics in simulation.",
132
+ ),
133
+ assigned_user_id: Optional[str] = typer.Option(
134
+ None,
135
+ "--assigned-user-id",
136
+ help="Optional assignee UUID for workforce simulation.",
137
+ ),
138
+ user_model: Optional[str] = typer.Option(
139
+ None,
140
+ "--user-model",
141
+ help="Optional simulated user model (for example: openai:gpt-5-nano).",
142
+ ),
143
+ user_temperature: Optional[float] = typer.Option(
144
+ None,
145
+ "--user-temperature",
146
+ help="Optional simulated user temperature (0.0-2.0).",
147
+ ),
148
+ seed: Optional[int] = typer.Option(
149
+ None,
150
+ "--seed",
151
+ help="Optional deterministic seed for user LLM timestamp sampling.",
152
+ ),
153
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
154
+ continue_on_error: bool = typer.Option(
155
+ False,
156
+ "--continue-on-error/--no-continue-on-error",
157
+ help="Continue remaining simulations even if one fails.",
158
+ ),
159
+ base_url: Optional[str] = typer.Option(None, help="Applied base URL."),
160
+ shop_id: Optional[str] = typer.Option(None, help="Target shop UUID."),
161
+ api_token: Optional[str] = typer.Option(None, help="Applied API token."),
162
+ ) -> None:
163
+ validate_uuid(agent_id, field_name="agent-id")
164
+ if experiment_id:
165
+ validate_uuid(experiment_id, field_name="experiment-id")
166
+ if traffic_rule_id:
167
+ validate_uuid(traffic_rule_id, field_name="traffic-rule-id")
168
+ if assigned_user_id:
169
+ validate_uuid(assigned_user_id, field_name="assigned-user-id")
170
+ if label_id:
171
+ validate_uuid(label_id, field_name="label-id")
172
+ if sublabel_id:
173
+ validate_uuid(sublabel_id, field_name="sublabel-id")
174
+ metadata = parse_json_dict(metadata_json, field_name="metadata-json")
175
+ if count < 1:
176
+ raise typer.BadParameter("count must be >= 1.")
177
+ if not (1 <= max_turns <= 10):
178
+ raise typer.BadParameter("max-turns must be between 1 and 10.")
179
+ if user_temperature is not None and not (0.0 <= user_temperature <= 2.0):
180
+ raise typer.BadParameter("user-temperature must be between 0.0 and 2.0.")
181
+ if conversation_type and conversation_type not in {"web_chat", "email", "sms", "phone_call"}:
182
+ raise typer.BadParameter(
183
+ "conversation-type must be one of: web_chat, email, sms, phone_call."
184
+ )
185
+ if direction and direction not in {"inbound", "outbound"}:
186
+ raise typer.BadParameter("direction must be one of: inbound, outbound.")
187
+ if bool(created_at_from) != bool(created_at_to):
188
+ raise typer.BadParameter(
189
+ "Provide both --created-at-from and --created-at-to, or neither."
190
+ )
191
+ if created_at and (created_at_from or created_at_to):
192
+ raise typer.BadParameter(
193
+ "Use either --created-at OR --created-at-from/--created-at-to."
194
+ )
195
+ if created_at_distribution not in {"flat", "daily_peaks", "weekly_peaks"}:
196
+ raise typer.BadParameter(
197
+ "created-at-distribution must be one of: flat, daily_peaks, weekly_peaks."
198
+ )
199
+
200
+ try:
201
+ resolved_base_url, resolved_shop_id, resolved_token = resolve_runtime(
202
+ base_url=base_url,
203
+ shop_id=shop_id,
204
+ api_token=api_token,
205
+ )
206
+ except APIError as exc:
207
+ typer.echo(render_api_error(exc, action="resolve runtime for simulation"), err=True)
208
+ raise typer.Exit(code=1) from exc
209
+
210
+ successes: list[dict[str, Any]] = []
211
+ failures: list[dict[str, Any]] = []
212
+ for idx in range(count):
213
+ payload: dict[str, Any] = {
214
+ "max_turns": max_turns,
215
+ "is_test": is_test,
216
+ "is_historical": is_historical,
217
+ "created_at_distribution": created_at_distribution,
218
+ "simulate_experiments": simulate_experiments,
219
+ "populate_workforce_actions": populate_workforce_actions,
220
+ }
221
+ if agent_subtype:
222
+ payload["agent_subtype"] = agent_subtype
223
+ if conversation_type:
224
+ payload["conversation_type"] = conversation_type
225
+ if direction:
226
+ payload["direction"] = direction
227
+ if remote_platform:
228
+ payload["remote_platform"] = remote_platform
229
+ if created_at:
230
+ payload["created_at"] = created_at
231
+ if created_at_from and created_at_to:
232
+ payload["created_at_from"] = created_at_from
233
+ payload["created_at_to"] = created_at_to
234
+ if label_id:
235
+ payload["label_id"] = label_id
236
+ if sublabel_id:
237
+ payload["sublabel_id"] = sublabel_id
238
+ if metadata is not None:
239
+ payload["metadata"] = metadata
240
+ if experiment_id:
241
+ payload["experiment_id"] = experiment_id
242
+ if traffic_rule_id:
243
+ payload["traffic_rule_id"] = traffic_rule_id
244
+ if assigned_user_id:
245
+ payload["assigned_user_id"] = assigned_user_id
246
+ if seed is not None or user_model is not None or user_temperature is not None:
247
+ payload["user_llm"] = {}
248
+ if seed is not None:
249
+ payload["user_llm"]["seed"] = seed + idx
250
+ if user_model is not None:
251
+ payload["user_llm"]["model"] = user_model
252
+ if user_temperature is not None:
253
+ payload["user_llm"]["temperature"] = user_temperature
254
+
255
+ try:
256
+ result = simulate_agent_conversation(
257
+ base_url=resolved_base_url,
258
+ shop_id=resolved_shop_id,
259
+ api_token=resolved_token,
260
+ agent_id=agent_id,
261
+ payload=payload,
262
+ )
263
+ successes.append(result)
264
+ except APIError as exc:
265
+ failure = {
266
+ "attempt": idx + 1,
267
+ "error": render_api_error(exc, action="simulate conversation"),
268
+ }
269
+ failures.append(failure)
270
+ if not continue_on_error:
271
+ if output_json:
272
+ typer.echo(
273
+ json.dumps(
274
+ {
275
+ "result": "failed",
276
+ "success_count": len(successes),
277
+ "failure_count": len(failures),
278
+ "failures": failures,
279
+ },
280
+ indent=2,
281
+ )
282
+ )
283
+ else:
284
+ typer.echo(failure["error"], err=True)
285
+ raise typer.Exit(code=1) from exc
286
+
287
+ summary = {
288
+ "result": "success" if not failures else "partial_success",
289
+ "agent_id": agent_id,
290
+ "count_requested": count,
291
+ "success_count": len(successes),
292
+ "failure_count": len(failures),
293
+ "is_test": is_test,
294
+ "is_historical": is_historical,
295
+ "simulate_experiments": simulate_experiments,
296
+ "populate_workforce_actions": populate_workforce_actions,
297
+ "created_at": created_at,
298
+ "created_at_from": created_at_from,
299
+ "created_at_to": created_at_to,
300
+ "created_at_distribution": created_at_distribution,
301
+ "simulations": successes if output_json else None,
302
+ "failures": failures if failures else None,
303
+ }
304
+
305
+ if output_json:
306
+ typer.echo(json.dumps(summary, indent=2, default=str))
307
+ return
308
+ conversation_ids = [
309
+ str(row.get("conversation_id"))
310
+ for row in successes
311
+ if isinstance(row, dict) and row.get("conversation_id")
312
+ ]
313
+ preview_ids = ",".join(conversation_ids[:5]) if conversation_ids else ""
314
+
315
+ typer.echo(
316
+ "result="
317
+ + summary["result"]
318
+ + " | "
319
+ + " | ".join(
320
+ [
321
+ f"agent_id={agent_id}",
322
+ f"requested={count}",
323
+ f"success={len(successes)}",
324
+ f"failed={len(failures)}",
325
+ f"is_test={is_test}",
326
+ f"is_historical={is_historical}",
327
+ f"conversation_ids={preview_ids}" if preview_ids else "conversation_ids=(none)",
328
+ ]
329
+ )
330
+ )
@@ -0,0 +1,238 @@
1
+ import json
2
+ import uuid
3
+ from typing import Any, Optional
4
+
5
+ import httpx
6
+ import typer
7
+
8
+ from applied_cli.commands.chat import (
9
+ _create_conversation,
10
+ _stream_v1_completion,
11
+ _verify_agent_scope,
12
+ )
13
+ from applied_cli.commands.rate import conversation as rate_conversation_command
14
+ from applied_cli.error_reporting import render_api_error
15
+ from applied_cli.http import APIError
16
+ from applied_cli.spec_workflow import (
17
+ apply_agent_settings_from_spec,
18
+ create_agent_from_spec,
19
+ load_and_validate_spec,
20
+ upsert_spec_responses,
21
+ )
22
+ from applied_cli.runtime import resolve_runtime
23
+
24
+ app = typer.Typer(help="Run spec-driven bootstrap/test/rate workflow.")
25
+
26
+
27
+ def _run_test_case(
28
+ *,
29
+ client: httpx.Client,
30
+ base_url: str,
31
+ shop_id: str,
32
+ api_token: str,
33
+ agent_id: str,
34
+ test_case: dict[str, Any],
35
+ ) -> dict[str, Any]:
36
+ channel = str(test_case["channel"])
37
+ conversation_id = _create_conversation(
38
+ client,
39
+ base_url=base_url,
40
+ agent_id=agent_id,
41
+ channel=channel,
42
+ )
43
+ transcript_results: list[dict[str, Any]] = []
44
+ for turn in test_case["turns"]:
45
+ transcript = [
46
+ {
47
+ "id": str(uuid.uuid4()),
48
+ "role": "user",
49
+ "content": turn,
50
+ "text": turn,
51
+ "format": "TEXT",
52
+ "entity": {"type": "user"},
53
+ }
54
+ ]
55
+ payload: dict[str, object] = {
56
+ "conversation_id": conversation_id,
57
+ "context": "EVALUATE",
58
+ "transcript": transcript,
59
+ "metadata": {
60
+ "source": "applied-cli-spec",
61
+ "isTest": True,
62
+ "channel": channel,
63
+ "test_name": test_case["name"],
64
+ },
65
+ "draft": False,
66
+ }
67
+ assistant_text = _stream_v1_completion(
68
+ client,
69
+ base_url=base_url,
70
+ shop_id=shop_id,
71
+ api_token=api_token,
72
+ agent_id=agent_id,
73
+ payload=payload,
74
+ )
75
+ transcript_results.append(
76
+ {"user": turn, "assistant": assistant_text, "assistant_chars": len(assistant_text)}
77
+ )
78
+ return {
79
+ "name": test_case["name"],
80
+ "channel": channel,
81
+ "conversation_id": conversation_id,
82
+ "turns": transcript_results,
83
+ }
84
+
85
+
86
+ @app.command("run")
87
+ def run(
88
+ spec: str = typer.Option(..., "--spec", help="Path to spec JSON."),
89
+ agent_id: Optional[str] = typer.Option(
90
+ None, "--agent-id", help="Reuse existing agent id instead of creating one."
91
+ ),
92
+ base_url: Optional[str] = typer.Option(None, help="Applied base URL."),
93
+ shop_id: Optional[str] = typer.Option(None, help="Target shop UUID."),
94
+ api_token: Optional[str] = typer.Option(None, help="Applied API token."),
95
+ dry_run: bool = typer.Option(False, help="Validate and print plan without writes."),
96
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
97
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
98
+ ) -> None:
99
+ if agent_id:
100
+ try:
101
+ uuid.UUID(agent_id)
102
+ except ValueError as exc:
103
+ raise typer.BadParameter("agent-id must be a valid UUID.") from exc
104
+ try:
105
+ validated_spec = load_and_validate_spec(spec)
106
+ resolved_base_url, resolved_shop_id, resolved_token = resolve_runtime(
107
+ base_url=base_url,
108
+ shop_id=shop_id,
109
+ api_token=api_token,
110
+ )
111
+ except ValueError as exc:
112
+ typer.echo(f"Spec validation failed: {exc}", err=True)
113
+ raise typer.Exit(code=2) from exc
114
+ except APIError as exc:
115
+ typer.echo(render_api_error(exc, action="resolve runtime for spec run"), err=True)
116
+ raise typer.Exit(code=1) from exc
117
+
118
+ typer.echo("Plan:")
119
+ typer.echo(f"- base_url: {resolved_base_url}")
120
+ typer.echo(f"- shop_id: {resolved_shop_id}")
121
+ typer.echo(f"- tests: {len(validated_spec['tests'])}")
122
+ typer.echo(f"- responses: {len(validated_spec['responses'])}")
123
+ typer.echo(f"- dry_run: {dry_run}")
124
+ if not yes and not typer.confirm("Continue?"):
125
+ raise typer.Exit(code=1)
126
+
127
+ working_agent_id = agent_id
128
+ try:
129
+ if working_agent_id:
130
+ apply_agent_settings_from_spec(
131
+ base_url=resolved_base_url,
132
+ shop_id=resolved_shop_id,
133
+ api_token=resolved_token,
134
+ agent_id=working_agent_id,
135
+ spec=validated_spec,
136
+ dry_run=dry_run,
137
+ )
138
+ upsert_spec_responses(
139
+ base_url=resolved_base_url,
140
+ shop_id=resolved_shop_id,
141
+ api_token=resolved_token,
142
+ agent_id=working_agent_id,
143
+ responses=validated_spec["responses"],
144
+ dry_run=dry_run,
145
+ )
146
+ else:
147
+ created = create_agent_from_spec(
148
+ base_url=resolved_base_url,
149
+ shop_id=resolved_shop_id,
150
+ api_token=resolved_token,
151
+ spec=validated_spec,
152
+ dry_run=dry_run,
153
+ )
154
+ working_agent_id = str(created.get("id"))
155
+ upsert_spec_responses(
156
+ base_url=resolved_base_url,
157
+ shop_id=resolved_shop_id,
158
+ api_token=resolved_token,
159
+ agent_id=working_agent_id,
160
+ responses=validated_spec["responses"],
161
+ dry_run=dry_run,
162
+ )
163
+ except APIError as exc:
164
+ typer.echo(render_api_error(exc, action="bootstrap spec run"), err=True)
165
+ raise typer.Exit(code=1) from exc
166
+
167
+ report: dict[str, Any] = {
168
+ "agent_id": working_agent_id,
169
+ "benchmark_name": validated_spec["benchmark_name"],
170
+ "tests": [],
171
+ "dry_run": dry_run,
172
+ }
173
+ if dry_run:
174
+ if output_json:
175
+ typer.echo(json.dumps(report, indent=2))
176
+ else:
177
+ typer.echo("Dry run complete. No tests executed.")
178
+ raise typer.Exit(code=0)
179
+
180
+ if not working_agent_id or working_agent_id == "dry-run":
181
+ typer.echo("Unable to resolve agent id for execution.", err=True)
182
+ raise typer.Exit(code=1)
183
+
184
+ with httpx.Client() as client:
185
+ try:
186
+ _verify_agent_scope(
187
+ client,
188
+ base_url=resolved_base_url,
189
+ shop_id=resolved_shop_id,
190
+ api_token=resolved_token,
191
+ agent_id=working_agent_id,
192
+ )
193
+ for test_case in validated_spec["tests"]:
194
+ typer.echo(f"\nRunning test: {test_case['name']}")
195
+ result = _run_test_case(
196
+ client=client,
197
+ base_url=resolved_base_url,
198
+ shop_id=resolved_shop_id,
199
+ api_token=resolved_token,
200
+ agent_id=working_agent_id,
201
+ test_case=test_case,
202
+ )
203
+ report["tests"].append(result)
204
+ except APIError as exc:
205
+ typer.echo(render_api_error(exc, action="run spec tests"), err=True)
206
+ raise typer.Exit(code=1) from exc
207
+
208
+ for test_result, test_case in zip(report["tests"], validated_spec["tests"]):
209
+ try:
210
+ rate_conversation_command(
211
+ conversation_id=test_result["conversation_id"],
212
+ agent_id=working_agent_id,
213
+ benchmark_name=validated_spec["benchmark_name"],
214
+ auto=bool(test_case.get("auto_rate", True)),
215
+ include_references=bool(test_case.get("include_references", True)),
216
+ pass_status=test_case.get("pass_status"),
217
+ csat_score=test_case.get("csat_score"),
218
+ feedback=test_case.get("feedback"),
219
+ reference_score=test_case.get("reference_score"),
220
+ reference_notes=test_case.get("reference_notes"),
221
+ base_url=resolved_base_url,
222
+ shop_id=resolved_shop_id,
223
+ api_token=resolved_token,
224
+ dry_run=False,
225
+ yes=True,
226
+ )
227
+ except Exception as exc:
228
+ typer.echo(
229
+ f"Rating failed for conversation {test_result['conversation_id']}: {exc}",
230
+ err=True,
231
+ )
232
+
233
+ if output_json:
234
+ typer.echo(json.dumps(report, indent=2, default=str))
235
+ else:
236
+ typer.echo("\nSpec run complete:")
237
+ typer.echo(f"- agent_id: {working_agent_id}")
238
+ typer.echo(f"- tests_executed: {len(report['tests'])}")
applied_cli/config.py ADDED
@@ -0,0 +1,50 @@
1
+ from dataclasses import dataclass
2
+ from urllib.parse import urlsplit
3
+
4
+
5
+ @dataclass
6
+ class Credentials:
7
+ base_url: str
8
+ shop_id: str
9
+ api_token: str
10
+
11
+
12
+ DEFAULT_BASE_URL = "https://api.appliedlabs.ai"
13
+ ENDPOINT_ALIASES = {
14
+ "prod": "https://api.appliedlabs.ai",
15
+ "production": "https://api.appliedlabs.ai",
16
+ "api.appliedlabs.ai": "https://api.appliedlabs.ai",
17
+ "dev": "https://api.appliedlabs.dev",
18
+ "development": "https://api.appliedlabs.dev",
19
+ "api.appliedlabs.dev": "https://api.appliedlabs.dev",
20
+ "local": "http://localhost:8000",
21
+ "localhost": "http://localhost:8000",
22
+ "127.0.0.1": "http://127.0.0.1:8000",
23
+ }
24
+
25
+
26
+ def normalize_base_url(value: str) -> str:
27
+ raw = value.strip()
28
+ if not raw:
29
+ return ""
30
+ alias = ENDPOINT_ALIASES.get(raw.lower())
31
+ if alias:
32
+ return alias
33
+
34
+ with_scheme = raw
35
+ if not with_scheme.startswith(("http://", "https://")):
36
+ if with_scheme.startswith(("localhost", "127.0.0.1")):
37
+ with_scheme = f"http://{with_scheme}"
38
+ else:
39
+ with_scheme = f"https://{with_scheme}"
40
+
41
+ parsed = urlsplit(with_scheme)
42
+ if parsed.scheme and parsed.netloc:
43
+ return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
44
+ return with_scheme.rstrip("/")
45
+
46
+
47
+ def mask_token(token: str) -> str:
48
+ if len(token) <= 10:
49
+ return token[:2] + "..." if len(token) > 2 else "***"
50
+ return f"{token[:4]}...{token[-4:]}"
@@ -0,0 +1,38 @@
1
+ from typing import Any
2
+
3
+ from applied_cli.http import APIError
4
+
5
+
6
+ def _extract_request_id(detail: Any) -> str | None:
7
+ if not isinstance(detail, dict):
8
+ return None
9
+ if isinstance(detail.get("request_id"), str):
10
+ return detail["request_id"]
11
+ error_payload = detail.get("error")
12
+ if isinstance(error_payload, dict) and isinstance(
13
+ error_payload.get("request_id"), str
14
+ ):
15
+ return error_payload["request_id"]
16
+ return None
17
+
18
+
19
+ def render_api_error(exc: APIError, *, action: str) -> str:
20
+ lines: list[str] = []
21
+ lines.append(f"Error during {action}.")
22
+ if exc.code:
23
+ lines.append(f"- code: {exc.code}")
24
+ if exc.status_code is not None:
25
+ lines.append(f"- http_status: {exc.status_code}")
26
+ if exc.method and exc.url:
27
+ lines.append(f"- request: {exc.method} {exc.url}")
28
+ lines.append(f"- message: {exc}")
29
+ if exc.hint:
30
+ lines.append(f"- fix: {exc.hint}")
31
+ request_id = _extract_request_id(exc.detail)
32
+ if request_id:
33
+ lines.append(f"- request_id: {request_id}")
34
+ if exc.suggestions:
35
+ lines.append("- next_steps:")
36
+ for step in exc.suggestions:
37
+ lines.append(f" - {step}")
38
+ return "\n".join(lines)