shopping-cli 3.0.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.
Files changed (82) hide show
  1. shopping_cli/__init__.py +3 -0
  2. shopping_cli/agents/__init__.py +1 -0
  3. shopping_cli/agents/agent_cli.py +31 -0
  4. shopping_cli/agents/buyer_cli.py +163 -0
  5. shopping_cli/agents/merchant_agent.py +263 -0
  6. shopping_cli/agents/merchant_daemon.py +872 -0
  7. shopping_cli/agents/tools.py +488 -0
  8. shopping_cli/api/__init__.py +1 -0
  9. shopping_cli/api/app.py +1353 -0
  10. shopping_cli/api/auth.py +125 -0
  11. shopping_cli/api/fallback_asgi.py +219 -0
  12. shopping_cli/api/handlers/__init__.py +1 -0
  13. shopping_cli/api/handlers/agents.py +277 -0
  14. shopping_cli/api/handlers/audit.py +97 -0
  15. shopping_cli/api/handlers/buyer.py +291 -0
  16. shopping_cli/api/handlers/catalog.py +392 -0
  17. shopping_cli/api/handlers/common.py +76 -0
  18. shopping_cli/api/handlers/conversations.py +172 -0
  19. shopping_cli/api/handlers/erp.py +127 -0
  20. shopping_cli/api/handlers/human_review.py +293 -0
  21. shopping_cli/api/handlers/listings_projection.py +42 -0
  22. shopping_cli/api/handlers/negotiation.py +121 -0
  23. shopping_cli/api/idempotency.py +291 -0
  24. shopping_cli/api/limits.py +59 -0
  25. shopping_cli/api/route_registry.py +105 -0
  26. shopping_cli/api/routes_agents.py +13 -0
  27. shopping_cli/api/routes_conversations.py +13 -0
  28. shopping_cli/api/routes_marketplace.py +13 -0
  29. shopping_cli/api/routes_merchants.py +13 -0
  30. shopping_cli/api/server.py +31 -0
  31. shopping_cli/cli.py +897 -0
  32. shopping_cli/cli_agent_commands.py +386 -0
  33. shopping_cli/cli_buyer_commands.py +231 -0
  34. shopping_cli/cli_catalog_commands.py +281 -0
  35. shopping_cli/cli_common.py +135 -0
  36. shopping_cli/cli_conversation_commands.py +167 -0
  37. shopping_cli/cli_erp_commands.py +60 -0
  38. shopping_cli/cli_listing_commands.py +35 -0
  39. shopping_cli/commerce/__init__.py +6 -0
  40. shopping_cli/commerce/adapters.py +310 -0
  41. shopping_cli/commerce/data_source.py +95 -0
  42. shopping_cli/config.py +262 -0
  43. shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +47 -0
  44. shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +120 -0
  45. shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +52 -0
  46. shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +172 -0
  47. shopping_cli/core/__init__.py +1 -0
  48. shopping_cli/core/catalog.py +1281 -0
  49. shopping_cli/core/channels.py +329 -0
  50. shopping_cli/core/conversations.py +502 -0
  51. shopping_cli/core/delivery.py +7 -0
  52. shopping_cli/core/errors.py +43 -0
  53. shopping_cli/core/harness.py +345 -0
  54. shopping_cli/core/limits.py +137 -0
  55. shopping_cli/core/negotiation.py +273 -0
  56. shopping_cli/core/policies.py +356 -0
  57. shopping_cli/core/risk.py +101 -0
  58. shopping_cli/core/tokens.py +33 -0
  59. shopping_cli/data_sources/__init__.py +12 -0
  60. shopping_cli/data_sources/erp_source.py +440 -0
  61. shopping_cli/db/__init__.py +1 -0
  62. shopping_cli/db/migrations.py +415 -0
  63. shopping_cli/db/models.py +325 -0
  64. shopping_cli/db/provenance.py +21 -0
  65. shopping_cli/db/session.py +108 -0
  66. shopping_cli/http_client.py +149 -0
  67. shopping_cli/listings/__init__.py +4 -0
  68. shopping_cli/listings/projection.py +148 -0
  69. shopping_cli/services/__init__.py +1 -0
  70. shopping_cli/services/agents.py +244 -0
  71. shopping_cli/services/audit.py +34 -0
  72. shopping_cli/services/buyer_bootstrap.py +23 -0
  73. shopping_cli/services/conversations.py +209 -0
  74. shopping_cli/services/human_review.py +165 -0
  75. shopping_cli/services/negotiation.py +929 -0
  76. shopping_cli/services/tokens.py +318 -0
  77. shopping_cli-3.0.0.dist-info/METADATA +219 -0
  78. shopping_cli-3.0.0.dist-info/RECORD +82 -0
  79. shopping_cli-3.0.0.dist-info/WHEEL +5 -0
  80. shopping_cli-3.0.0.dist-info/entry_points.txt +4 -0
  81. shopping_cli-3.0.0.dist-info/licenses/LICENSE +201 -0
  82. shopping_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1353 @@
1
+ """Marketplace API app factory.
2
+
3
+ FastAPI is used when installed. The lightweight fallback keeps route metadata
4
+ available for local tests in environments where optional API dependencies have
5
+ not been installed yet.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import re
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from types import SimpleNamespace
16
+ from typing import Any, Callable
17
+
18
+ from shopping_cli import VERSION
19
+ from shopping_cli.api import auth as api_auth
20
+ from shopping_cli.api.fallback_asgi import MarketplaceASGIApp
21
+ from shopping_cli.api.handlers import agents as agent_handlers
22
+ from shopping_cli.api.handlers import audit as audit_handlers
23
+ from shopping_cli.api.handlers import buyer as buyer_handlers
24
+ from shopping_cli.api.handlers import catalog as catalog_handlers
25
+ from shopping_cli.api.handlers import conversations as conversation_handlers
26
+ from shopping_cli.api.handlers import erp as erp_handlers
27
+ from shopping_cli.api.handlers import listings_projection as listings_projection_handlers
28
+ from shopping_cli.api.handlers import human_review as human_review_handlers
29
+ from shopping_cli.api.handlers import negotiation as negotiation_handlers
30
+ from shopping_cli.api.handlers.common import DEFAULT_RESULT_LIMIT
31
+ from shopping_cli.api.limits import max_request_body_bytes, validate_payload
32
+ from shopping_cli.core.errors import (
33
+ AuthError,
34
+ ConflictError,
35
+ IdempotencyConflict,
36
+ MethodNotAllowedError,
37
+ NotFoundError,
38
+ PermissionDenied,
39
+ RateLimitError,
40
+ PayloadTooLargeError,
41
+ ShoppingCliError,
42
+ ValidationError,
43
+ )
44
+ from shopping_cli.services import tokens as token_service
45
+
46
+ logger = logging.getLogger("shopping-cli")
47
+
48
+ try: # pragma: no cover - exercised when optional dependency is installed
49
+ from fastapi import FastAPI, Header, Request, Response
50
+ from fastapi.exceptions import RequestValidationError
51
+ from fastapi.responses import JSONResponse
52
+ from starlette.exceptions import HTTPException as StarletteHTTPException
53
+ except ModuleNotFoundError: # pragma: no cover - local CI currently has no fastapi
54
+ FastAPI = None # type: ignore[misc,assignment]
55
+ Header = None # type: ignore[assignment]
56
+ Response = None # type: ignore[misc,assignment]
57
+ JSONResponse = None # type: ignore[misc,assignment]
58
+ RequestValidationError = None # type: ignore[misc,assignment]
59
+ Request = None # type: ignore[misc,assignment]
60
+ StarletteHTTPException = None # type: ignore[misc,assignment]
61
+
62
+
63
+ # Route handlers return the JSON response body (a dict).
64
+ Handler = Callable[..., Any]
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class RouteEntry:
69
+ methods: set[str]
70
+ path_template: str
71
+ handler: Handler
72
+
73
+
74
+ class _RequestBodyLimitMiddleware:
75
+ """Reject oversized HTTP bodies before FastAPI attempts to parse them."""
76
+
77
+ def __init__(self, app: Any) -> None:
78
+ self.app = app
79
+
80
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
81
+ if scope.get("type") != "http":
82
+ await self.app(scope, receive, send)
83
+ return
84
+
85
+ maximum = max_request_body_bytes()
86
+ headers = {key.lower(): value for key, value in scope.get("headers", [])}
87
+ try:
88
+ content_length = int(headers.get(b"content-length", b"0") or b"0")
89
+ except ValueError:
90
+ content_length = 0
91
+ if content_length > maximum:
92
+ await self._send_too_large(send)
93
+ return
94
+
95
+ messages: list[dict[str, Any]] = []
96
+ received = 0
97
+ while True:
98
+ message = await receive()
99
+ messages.append(message)
100
+ if message.get("type") != "http.request":
101
+ break
102
+ received += len(message.get("body", b""))
103
+ if received > maximum:
104
+ await self._send_too_large(send)
105
+ return
106
+ if not message.get("more_body", False):
107
+ break
108
+
109
+ message_index = 0
110
+
111
+ async def replay_receive() -> dict[str, Any]:
112
+ nonlocal message_index
113
+ if message_index < len(messages):
114
+ message = messages[message_index]
115
+ message_index += 1
116
+ return message
117
+ return {"type": "http.request", "body": b"", "more_body": False}
118
+
119
+ await self.app(scope, replay_receive, send)
120
+
121
+ @staticmethod
122
+ async def _send_too_large(send: Any) -> None:
123
+ body = json.dumps({"ok": False, "error": "request body is too large"}).encode("utf-8")
124
+ await send(
125
+ {
126
+ "type": "http.response.start",
127
+ "status": 413,
128
+ "headers": [
129
+ (b"content-type", b"application/json"),
130
+ (b"content-length", str(len(body)).encode("ascii")),
131
+ ],
132
+ }
133
+ )
134
+ await send({"type": "http.response.body", "body": body})
135
+
136
+
137
+ def _json_error_response(status_code: int, error: str) -> Any:
138
+ payload = {"ok": False, "error": error}
139
+ if JSONResponse is not None: # pragma: no cover - exercised with fastapi installed
140
+ return JSONResponse(status_code=status_code, content=payload)
141
+ return SimpleNamespace(status_code=status_code, body=json.dumps(payload, ensure_ascii=False).encode("utf-8"))
142
+
143
+
144
+ def _auth_header_default() -> Any:
145
+ if Header is None:
146
+ return ""
147
+ return Header(default="")
148
+
149
+
150
+ AUTHORIZATION_HEADER = _auth_header_default()
151
+
152
+
153
+ def _idempotency_key_header_default() -> Any:
154
+ if Header is None:
155
+ return ""
156
+ return Header(default="", alias="Idempotency-Key")
157
+
158
+
159
+ IDEMPOTENCY_KEY_HEADER = _idempotency_key_header_default()
160
+
161
+
162
+ def _require_merchant_token(conn: Any, merchant_id: str, payload: dict[str, Any]) -> None:
163
+ token_service.require_merchant_token(conn, merchant_id, api_auth.payload_token(payload))
164
+
165
+
166
+ def _resolve_agent_token(conn: Any, merchant_id: str, token: Any = "", token_prefix: Any = "") -> str:
167
+ return token_service.resolve_agent_token(conn, merchant_id, token, token_prefix)
168
+
169
+
170
+ def _health(db_path: str | Path) -> dict[str, Any]:
171
+ return catalog_handlers.health(db_path)
172
+
173
+
174
+ def _create_merchant(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
175
+ return catalog_handlers.create_merchant(db_path, payload, api_auth.require_admin_token)
176
+
177
+
178
+ def _update_merchant(db_path: str | Path, merchant_id: str, payload: dict[str, Any]) -> dict[str, Any]:
179
+ return catalog_handlers.update_merchant(db_path, merchant_id, payload, _require_merchant_token)
180
+
181
+
182
+ def _rotate_merchant_token(db_path: str | Path, merchant_id: str, payload: dict[str, Any]) -> dict[str, Any]:
183
+ return catalog_handlers.rotate_merchant_token(db_path, merchant_id, payload, api_auth.require_admin_token)
184
+
185
+
186
+ def _revoke_merchant_tokens(db_path: str | Path, merchant_id: str, payload: dict[str, Any]) -> dict[str, Any]:
187
+ return catalog_handlers.revoke_merchant_tokens(db_path, merchant_id, payload, api_auth.require_admin_token)
188
+
189
+
190
+ def _recover_merchant_token(db_path: str | Path, merchant_id: str, payload: dict[str, Any]) -> dict[str, Any]:
191
+ return catalog_handlers.recover_merchant_token(db_path, merchant_id, payload, api_auth.require_admin_token)
192
+
193
+
194
+ def _get_merchant(db_path: str | Path, merchant_id: str) -> dict[str, Any]:
195
+ return catalog_handlers.get_merchant(db_path, merchant_id)
196
+
197
+
198
+ def _get_merchant_private_config(
199
+ db_path: str | Path,
200
+ merchant_id: str,
201
+ payload: dict[str, Any],
202
+ ) -> dict[str, Any]:
203
+ return catalog_handlers.get_merchant_private_config(db_path, merchant_id, payload)
204
+
205
+
206
+ def _list_merchants(db_path: str | Path, query: dict[str, Any] | None = None) -> dict[str, Any]:
207
+ return catalog_handlers.list_merchants(db_path, query)
208
+
209
+
210
+ def _create_product(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
211
+ return catalog_handlers.create_product(db_path, payload, _require_merchant_token)
212
+
213
+
214
+ def _update_product(db_path: str | Path, sku: str, payload: dict[str, Any]) -> dict[str, Any]:
215
+ return catalog_handlers.update_product(db_path, sku, payload, _require_merchant_token)
216
+
217
+
218
+ def _get_product(db_path: str | Path, sku: str) -> dict[str, Any]:
219
+ return catalog_handlers.get_product(db_path, sku)
220
+
221
+
222
+ def _list_listing_projections(db_path: str | Path, query: dict[str, Any]) -> dict[str, Any]:
223
+ return listings_projection_handlers.list_listing_projections(db_path, query)
224
+
225
+
226
+ def _sync_erp(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
227
+ return erp_handlers.sync_erp(db_path, payload)
228
+
229
+
230
+ def _get_listing_projection(db_path: str | Path, sku: str) -> dict[str, Any]:
231
+ return listings_projection_handlers.get_listing_projection(db_path, sku)
232
+
233
+
234
+ def _search_products(db_path: str | Path, query: dict[str, Any]) -> dict[str, Any]:
235
+ return catalog_handlers.search_products(db_path, query)
236
+
237
+
238
+ def _search_merchants(db_path: str | Path, query: dict[str, Any]) -> dict[str, Any]:
239
+ return catalog_handlers.search_merchants(db_path, query)
240
+
241
+
242
+ def _buyer_ask(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
243
+ return buyer_handlers.buyer_ask(db_path, payload)
244
+
245
+
246
+ def _ingest_channel_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
247
+ return buyer_handlers.ingest_channel_message(db_path, payload)
248
+
249
+
250
+ def _get_conversation(db_path: str | Path, conversation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
251
+ return conversation_handlers.get_conversation(db_path, conversation_id, payload)
252
+
253
+
254
+ def _create_conversation(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
255
+ return buyer_handlers.create_conversation(db_path, payload)
256
+
257
+
258
+ def _append_conversation_message(db_path: str | Path, conversation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
259
+ return conversation_handlers.append_conversation_message(db_path, conversation_id, payload)
260
+
261
+
262
+ def _close_conversation(db_path: str | Path, conversation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
263
+ return conversation_handlers.close_conversation(db_path, conversation_id, payload)
264
+
265
+
266
+ def _agent_heartbeat(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
267
+ return agent_handlers.agent_heartbeat(db_path, payload)
268
+
269
+
270
+ def _create_agent_token(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
271
+ return agent_handlers.create_agent_token(db_path, payload)
272
+
273
+
274
+ def _list_agent_tokens(
275
+ db_path: str | Path,
276
+ payload: dict[str, Any],
277
+ merchant_id: str = "",
278
+ limit: Any = DEFAULT_RESULT_LIMIT,
279
+ offset: Any = 0,
280
+ ) -> dict[str, Any]:
281
+ return agent_handlers.list_agent_tokens(db_path, payload, merchant_id=merchant_id, limit=limit, offset=offset)
282
+
283
+
284
+ def _revoke_agent_token(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
285
+ return agent_handlers.revoke_agent_token(db_path, payload)
286
+
287
+
288
+ def _rotate_agent_token(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
289
+ return agent_handlers.rotate_agent_token(db_path, payload)
290
+
291
+
292
+ def _claim_agent_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
293
+ return agent_handlers.claim_message(db_path, payload)
294
+
295
+
296
+ def _complete_agent_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
297
+ return agent_handlers.complete_message(db_path, payload)
298
+
299
+
300
+ def _fail_agent_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
301
+ return agent_handlers.fail_message(db_path, payload)
302
+
303
+
304
+ def _abandon_agent_message(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
305
+ return agent_handlers.abandon_message(db_path, payload)
306
+
307
+
308
+ def _abandon_stale_agent_messages(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
309
+ return agent_handlers.abandon_stale_messages(db_path, payload)
310
+
311
+
312
+ def _list_agents(
313
+ db_path: str | Path,
314
+ payload: dict[str, Any],
315
+ owner_id: str = "",
316
+ limit: Any = DEFAULT_RESULT_LIMIT,
317
+ offset: Any = 0,
318
+ ) -> dict[str, Any]:
319
+ return agent_handlers.list_agents(db_path, payload, owner_id=owner_id, limit=limit, offset=offset)
320
+
321
+
322
+ def _get_agent(db_path: str | Path, agent_id: str, payload: dict[str, Any]) -> dict[str, Any]:
323
+ return agent_handlers.get_agent(db_path, agent_id, payload)
324
+
325
+
326
+ def _conversation_list(
327
+ db_path: str | Path,
328
+ filters: dict[str, Any],
329
+ payload: dict[str, Any],
330
+ owner_kind: str,
331
+ owner_id: str,
332
+ ) -> dict[str, Any]:
333
+ return conversation_handlers.conversation_list(db_path, filters, payload, owner_kind, owner_id)
334
+
335
+
336
+ def _merchant_conversations(
337
+ db_path: str | Path,
338
+ merchant_id: str,
339
+ payload: dict[str, Any],
340
+ status: str = "",
341
+ limit: Any = DEFAULT_RESULT_LIMIT,
342
+ offset: Any = 0,
343
+ include: Any = "",
344
+ ) -> dict[str, Any]:
345
+ return conversation_handlers.merchant_conversations(
346
+ db_path,
347
+ merchant_id,
348
+ payload,
349
+ status=status,
350
+ limit=limit,
351
+ offset=offset,
352
+ include=include,
353
+ )
354
+
355
+
356
+ def _human_review_queue(
357
+ db_path: str | Path,
358
+ payload: dict[str, Any],
359
+ merchant_id: str = "",
360
+ limit: Any = DEFAULT_RESULT_LIMIT,
361
+ offset: Any = 0,
362
+ ) -> dict[str, Any]:
363
+ return human_review_handlers.human_review_queue(
364
+ db_path,
365
+ payload,
366
+ merchant_id=merchant_id,
367
+ limit=limit,
368
+ offset=offset,
369
+ )
370
+
371
+
372
+ def _record_tool_call_audit(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
373
+ return audit_handlers.record_tool_call_audit(db_path, payload)
374
+
375
+
376
+ def _audit_events(
377
+ db_path: str | Path,
378
+ payload: dict[str, Any],
379
+ merchant_id: str = "",
380
+ event: str = "",
381
+ limit: Any = 50,
382
+ offset: Any = 0,
383
+ ) -> dict[str, Any]:
384
+ return audit_handlers.audit_events(
385
+ db_path,
386
+ payload,
387
+ merchant_id=merchant_id,
388
+ event=event,
389
+ limit=limit,
390
+ offset=offset,
391
+ )
392
+
393
+
394
+ def _get_human_review(db_path: str | Path, review_id: str | int, payload: dict[str, Any]) -> dict[str, Any]:
395
+ return human_review_handlers.get_human_review(db_path, review_id, payload)
396
+
397
+
398
+ def _create_human_review(db_path: str | Path, conversation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
399
+ return human_review_handlers.create_human_review(db_path, conversation_id, payload)
400
+
401
+
402
+ def _resolve_human_review_item(db_path: str | Path, review_id: str | int, payload: dict[str, Any]) -> dict[str, Any]:
403
+ return human_review_handlers.resolve_human_review_item(db_path, review_id, payload)
404
+
405
+
406
+ def _resolve_human_review(db_path: str | Path, conversation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
407
+ return human_review_handlers.resolve_human_review(db_path, conversation_id, payload)
408
+
409
+
410
+ def _capabilities(db_path: str | Path) -> dict[str, Any]:
411
+ return negotiation_handlers.capabilities(db_path)
412
+
413
+
414
+ def _negotiation_pending_messages(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
415
+ return negotiation_handlers.pending_messages(db_path, payload)
416
+
417
+
418
+ def _negotiation_claim(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
419
+ return negotiation_handlers.claim_message(db_path, payload)
420
+
421
+
422
+ def _negotiation_complete_claim(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
423
+ return negotiation_handlers.complete_claim(db_path, payload)
424
+
425
+
426
+ def _negotiation_fail_claim(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
427
+ return negotiation_handlers.fail_claim(db_path, payload)
428
+
429
+
430
+ def _negotiation_abandon_claim(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
431
+ return negotiation_handlers.abandon_claim(db_path, payload)
432
+
433
+
434
+ def _negotiation_heartbeat_claims(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
435
+ return negotiation_handlers.heartbeat_claims(db_path, payload)
436
+
437
+
438
+ def _negotiation_abandon_stale_claims(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
439
+ return negotiation_handlers.abandon_stale_claims(db_path, payload)
440
+
441
+
442
+ def _negotiation_snapshot(db_path: str | Path, payload: dict[str, Any], query: dict[str, Any]) -> dict[str, Any]:
443
+ return negotiation_handlers.get_snapshot(db_path, payload, query)
444
+
445
+
446
+ def _negotiation_submit_decision(db_path: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
447
+ return negotiation_handlers.submit_decision(db_path, payload)
448
+
449
+
450
+ def _match_path(template: str, path: str) -> dict[str, str] | None:
451
+ parts = template.split("/")
452
+ regex_parts = []
453
+ for part in parts:
454
+ if part.startswith("{") and part.endswith("}"):
455
+ param_name = part[1:-1]
456
+ regex_parts.append(f"(?P<{param_name}>[^/]+)")
457
+ else:
458
+ regex_parts.append(re.escape(part))
459
+ match = re.match("^" + "/".join(regex_parts) + "$", path)
460
+ return match.groupdict() if match else None
461
+
462
+
463
+ def _buyer_conversation_list(
464
+ db_path: str | Path, payload: dict[str, Any], query: dict[str, Any], buyer_id: str
465
+ ) -> dict[str, Any]:
466
+ filters = dict(query)
467
+ filters["buyer_id"] = buyer_id
468
+ return _conversation_list(db_path, filters, payload, owner_kind="buyer", owner_id=buyer_id)
469
+
470
+
471
+ def _merchant_conversation_list(
472
+ db_path: str | Path, payload: dict[str, Any], query: dict[str, Any], merchant_id: str
473
+ ) -> dict[str, Any]:
474
+ filters = dict(query)
475
+ filters["merchant_id"] = merchant_id
476
+ return _conversation_list(db_path, filters, payload, owner_kind="merchant", owner_id=merchant_id)
477
+
478
+
479
+ def _merchant_human_review_list(
480
+ db_path: str | Path, payload: dict[str, Any], query: dict[str, Any], merchant_id: str
481
+ ) -> dict[str, Any]:
482
+ return _merchant_conversations(
483
+ db_path,
484
+ merchant_id,
485
+ payload,
486
+ status="human_required",
487
+ limit=query.get("limit"),
488
+ offset=query.get("offset"),
489
+ include=query.get("include"),
490
+ )
491
+
492
+
493
+ _ROUTE_TABLE: tuple[RouteEntry, ...] = (
494
+ RouteEntry({"GET"}, "/health", lambda db_path, payload, query, **kw: _health(db_path)),
495
+ RouteEntry({"GET"}, "/merchants", lambda db_path, payload, query, **kw: _list_merchants(db_path, query)),
496
+ RouteEntry({"POST"}, "/merchants", lambda db_path, payload, query, **kw: _create_merchant(db_path, payload)),
497
+ RouteEntry(
498
+ {"GET"},
499
+ "/merchants/{merchant_id}",
500
+ lambda db_path, payload, query, merchant_id: _get_merchant(db_path, merchant_id),
501
+ ),
502
+ RouteEntry(
503
+ {"GET"},
504
+ "/merchants/{merchant_id}/private-config",
505
+ lambda db_path, payload, query, merchant_id: _get_merchant_private_config(db_path, merchant_id, payload),
506
+ ),
507
+ RouteEntry(
508
+ {"POST"},
509
+ "/merchants/{merchant_id}/token/rotate",
510
+ lambda db_path, payload, query, merchant_id: _rotate_merchant_token(db_path, merchant_id, payload),
511
+ ),
512
+ RouteEntry(
513
+ {"POST"},
514
+ "/merchants/{merchant_id}/token/revoke",
515
+ lambda db_path, payload, query, merchant_id: _revoke_merchant_tokens(db_path, merchant_id, payload),
516
+ ),
517
+ RouteEntry(
518
+ {"POST"},
519
+ "/merchants/{merchant_id}/token/recover",
520
+ lambda db_path, payload, query, merchant_id: _recover_merchant_token(db_path, merchant_id, payload),
521
+ ),
522
+ RouteEntry(
523
+ {"PATCH"},
524
+ "/merchants/{merchant_id}",
525
+ lambda db_path, payload, query, merchant_id: _update_merchant(db_path, merchant_id, payload),
526
+ ),
527
+ RouteEntry({"GET"}, "/merchants/{merchant_id}/conversations", _merchant_conversation_list),
528
+ RouteEntry({"GET"}, "/merchants/{merchant_id}/human-review", _merchant_human_review_list),
529
+ RouteEntry(
530
+ {"GET"},
531
+ "/merchants/{merchant_id}/agents",
532
+ lambda db_path, payload, query, merchant_id: _list_agents(
533
+ db_path,
534
+ payload,
535
+ owner_id=merchant_id,
536
+ limit=query.get("limit"),
537
+ offset=query.get("offset"),
538
+ ),
539
+ ),
540
+ RouteEntry({"POST"}, "/products", lambda db_path, payload, query, **kw: _create_product(db_path, payload)),
541
+ RouteEntry({"GET"}, "/products/{sku}", lambda db_path, payload, query, sku: _get_product(db_path, sku)),
542
+ RouteEntry(
543
+ {"PATCH"}, "/products/{sku}", lambda db_path, payload, query, sku: _update_product(db_path, sku, payload)
544
+ ),
545
+ RouteEntry({"GET"}, "/search/products", lambda db_path, payload, query, **kw: _search_products(db_path, query)),
546
+ RouteEntry(
547
+ {"GET"},
548
+ "/v1/merchant/listings/projections",
549
+ lambda db_path, payload, query, **kw: _list_listing_projections(db_path, query or {}),
550
+ ),
551
+ RouteEntry(
552
+ {"GET"},
553
+ "/v1/merchant/listings/{sku}/projection",
554
+ lambda db_path, payload, query, sku: _get_listing_projection(db_path, sku),
555
+ ),
556
+ RouteEntry(
557
+ {"POST"},
558
+ "/v1/merchant/erp/sync",
559
+ lambda db_path, payload, query, **kw: _sync_erp(db_path, payload),
560
+ ),
561
+ RouteEntry({"GET"}, "/search/merchants", lambda db_path, payload, query, **kw: _search_merchants(db_path, query)),
562
+ RouteEntry(
563
+ {"POST"}, "/channels/messages", lambda db_path, payload, query, **kw: _ingest_channel_message(db_path, payload)
564
+ ),
565
+ RouteEntry({"POST"}, "/buyer/ask", lambda db_path, payload, query, **kw: _buyer_ask(db_path, payload)),
566
+ RouteEntry(
567
+ {"POST"}, "/conversations", lambda db_path, payload, query, **kw: _create_conversation(db_path, payload)
568
+ ),
569
+ RouteEntry(
570
+ {"GET"},
571
+ "/conversations/{conversation_id}",
572
+ lambda db_path, payload, query, conversation_id: _get_conversation(db_path, conversation_id, payload),
573
+ ),
574
+ RouteEntry(
575
+ {"POST"},
576
+ "/conversations/{conversation_id}/messages",
577
+ lambda db_path, payload, query, conversation_id: _append_conversation_message(
578
+ db_path, conversation_id, payload
579
+ ),
580
+ ),
581
+ RouteEntry(
582
+ {"POST"},
583
+ "/conversations/{conversation_id}/close",
584
+ lambda db_path, payload, query, conversation_id: _close_conversation(db_path, conversation_id, payload),
585
+ ),
586
+ RouteEntry(
587
+ {"POST"},
588
+ "/conversations/{conversation_id}/human-review",
589
+ lambda db_path, payload, query, conversation_id: _create_human_review(db_path, conversation_id, payload),
590
+ ),
591
+ RouteEntry(
592
+ {"POST"},
593
+ "/conversations/{conversation_id}/human-review/resolve",
594
+ lambda db_path, payload, query, conversation_id: _resolve_human_review(db_path, conversation_id, payload),
595
+ ),
596
+ RouteEntry({"GET"}, "/buyers/{buyer_id}/conversations", _buyer_conversation_list),
597
+ RouteEntry({"POST"}, "/agents/heartbeat", lambda db_path, payload, query, **kw: _agent_heartbeat(db_path, payload)),
598
+ RouteEntry(
599
+ {"GET"},
600
+ "/agents/tokens",
601
+ lambda db_path, payload, query, **kw: _list_agent_tokens(
602
+ db_path,
603
+ payload,
604
+ merchant_id=str(query.get("merchant_id") or ""),
605
+ limit=query.get("limit"),
606
+ offset=query.get("offset"),
607
+ ),
608
+ ),
609
+ RouteEntry({"POST"}, "/agents/tokens", lambda db_path, payload, query, **kw: _create_agent_token(db_path, payload)),
610
+ RouteEntry(
611
+ {"POST"}, "/agents/tokens/revoke", lambda db_path, payload, query, **kw: _revoke_agent_token(db_path, payload)
612
+ ),
613
+ RouteEntry(
614
+ {"POST"}, "/agents/tokens/rotate", lambda db_path, payload, query, **kw: _rotate_agent_token(db_path, payload)
615
+ ),
616
+ RouteEntry(
617
+ {"POST"}, "/agents/messages/claim", lambda db_path, payload, query, **kw: _claim_agent_message(db_path, payload)
618
+ ),
619
+ RouteEntry(
620
+ {"POST"},
621
+ "/agents/messages/complete",
622
+ lambda db_path, payload, query, **kw: _complete_agent_message(db_path, payload),
623
+ ),
624
+ RouteEntry(
625
+ {"POST"}, "/agents/messages/fail", lambda db_path, payload, query, **kw: _fail_agent_message(db_path, payload)
626
+ ),
627
+ RouteEntry(
628
+ {"POST"},
629
+ "/agents/messages/abandon",
630
+ lambda db_path, payload, query, **kw: _abandon_agent_message(db_path, payload),
631
+ ),
632
+ RouteEntry(
633
+ {"POST"},
634
+ "/agents/messages/abandon-stale",
635
+ lambda db_path, payload, query, **kw: _abandon_stale_agent_messages(db_path, payload),
636
+ ),
637
+ RouteEntry(
638
+ {"GET"},
639
+ "/agents",
640
+ lambda db_path, payload, query, **kw: _list_agents(
641
+ db_path,
642
+ payload,
643
+ limit=query.get("limit"),
644
+ offset=query.get("offset"),
645
+ ),
646
+ ),
647
+ RouteEntry(
648
+ {"GET"}, "/agents/{agent_id}", lambda db_path, payload, query, agent_id: _get_agent(db_path, agent_id, payload)
649
+ ),
650
+ RouteEntry(
651
+ {"POST"}, "/audit/tool-calls", lambda db_path, payload, query, **kw: _record_tool_call_audit(db_path, payload)
652
+ ),
653
+ RouteEntry(
654
+ {"GET"},
655
+ "/audit/events",
656
+ lambda db_path, payload, query, **kw: _audit_events(
657
+ db_path,
658
+ payload,
659
+ merchant_id=str(query.get("merchant_id") or ""),
660
+ event=str(query.get("event") or ""),
661
+ limit=query.get("limit") or 50,
662
+ offset=query.get("offset"),
663
+ ),
664
+ ),
665
+ RouteEntry(
666
+ {"GET"},
667
+ "/human-review/queue",
668
+ lambda db_path, payload, query, **kw: _human_review_queue(
669
+ db_path,
670
+ payload,
671
+ merchant_id=str(query.get("merchant_id") or ""),
672
+ limit=query.get("limit"),
673
+ offset=query.get("offset"),
674
+ ),
675
+ ),
676
+ RouteEntry(
677
+ {"GET"},
678
+ "/human-review/{review_id}",
679
+ lambda db_path, payload, query, review_id: _get_human_review(db_path, review_id, payload),
680
+ ),
681
+ RouteEntry(
682
+ {"POST"},
683
+ "/human-review/{review_id}/resolve",
684
+ lambda db_path, payload, query, review_id: _resolve_human_review_item(db_path, review_id, payload),
685
+ ),
686
+ RouteEntry({"GET"}, "/capabilities", lambda db_path, payload, query, **kw: _capabilities(db_path)),
687
+ RouteEntry(
688
+ {"GET"},
689
+ "/negotiation/pending-messages",
690
+ lambda db_path, payload, query, **kw: _negotiation_pending_messages(db_path, payload),
691
+ ),
692
+ RouteEntry(
693
+ {"POST"}, "/negotiation/claims", lambda db_path, payload, query, **kw: _negotiation_claim(db_path, payload)
694
+ ),
695
+ RouteEntry(
696
+ {"POST"},
697
+ "/negotiation/claims/complete",
698
+ lambda db_path, payload, query, **kw: _negotiation_complete_claim(db_path, payload),
699
+ ),
700
+ RouteEntry(
701
+ {"POST"},
702
+ "/negotiation/claims/fail",
703
+ lambda db_path, payload, query, **kw: _negotiation_fail_claim(db_path, payload),
704
+ ),
705
+ RouteEntry(
706
+ {"POST"},
707
+ "/negotiation/claims/abandon",
708
+ lambda db_path, payload, query, **kw: _negotiation_abandon_claim(db_path, payload),
709
+ ),
710
+ RouteEntry(
711
+ {"POST"},
712
+ "/negotiation/claims/heartbeat",
713
+ lambda db_path, payload, query, **kw: _negotiation_heartbeat_claims(db_path, payload),
714
+ ),
715
+ RouteEntry(
716
+ {"POST"},
717
+ "/negotiation/claims/abandon-stale",
718
+ lambda db_path, payload, query, **kw: _negotiation_abandon_stale_claims(db_path, payload),
719
+ ),
720
+ RouteEntry(
721
+ {"GET"},
722
+ "/negotiation/snapshot",
723
+ lambda db_path, payload, query, **kw: _negotiation_snapshot(db_path, payload, query),
724
+ ),
725
+ RouteEntry(
726
+ {"POST"},
727
+ "/negotiation/decisions",
728
+ lambda db_path, payload, query, **kw: _negotiation_submit_decision(db_path, payload),
729
+ ),
730
+ )
731
+
732
+
733
+ def resolve_route(
734
+ method: str, path: str, routes: tuple[RouteEntry, ...] | list[Any] | None = None
735
+ ) -> tuple[bool, bool]:
736
+ """Return (path_known, method_allowed) without parsing the request body.
737
+
738
+ *routes* defaults to the full ``_ROUTE_TABLE``; the parameter is kept for
739
+ API compatibility (previously used by the kiwi-catalog standalone service).
740
+ """
741
+ table = _ROUTE_TABLE if routes is None else tuple(routes)
742
+ path_known = False
743
+ for route in table:
744
+ template = getattr(route, "path_template", None) or getattr(route, "path", "")
745
+ if _match_path(template, path) is None:
746
+ continue
747
+ path_known = True
748
+ if method.upper() in route.methods:
749
+ return True, True
750
+ return path_known, False
751
+
752
+
753
+ def handle_request(
754
+ db_path: str | Path,
755
+ method: str,
756
+ path: str,
757
+ payload: dict[str, Any] | None = None,
758
+ query: dict[str, Any] | None = None,
759
+ ) -> tuple[int, dict[str, Any]]:
760
+ payload = payload or {}
761
+ query = query or {}
762
+ try:
763
+ validate_payload(payload)
764
+ path_matched = False
765
+ for route in _ROUTE_TABLE:
766
+ path_params = _match_path(route.path_template, path)
767
+ if path_params is None:
768
+ continue
769
+ path_matched = True
770
+ if method.upper() in route.methods:
771
+ result = route.handler(db_path, payload, query, **path_params)
772
+ return 200, result
773
+ if path_matched:
774
+ raise MethodNotAllowedError(f"Method not allowed for {method} {path}")
775
+ raise NotFoundError(f"No route for {method} {path}")
776
+ except AuthError as exc:
777
+ return 403, {"ok": False, "error": str(exc)}
778
+ except PermissionDenied as exc:
779
+ return 403, {"ok": False, "error": str(exc)}
780
+ except IdempotencyConflict as exc:
781
+ return 409, {"ok": False, "error": str(exc)}
782
+ except ConflictError as exc:
783
+ return 409, {"ok": False, "error": str(exc)}
784
+ except NotFoundError as exc:
785
+ return 404, {"ok": False, "error": str(exc)}
786
+ except RateLimitError as exc:
787
+ return 429, {"ok": False, "error": str(exc)}
788
+ except PayloadTooLargeError as exc:
789
+ return 413, {"ok": False, "error": str(exc)}
790
+ except MethodNotAllowedError as exc:
791
+ return 405, {"ok": False, "error": str(exc)}
792
+ except ValidationError as exc:
793
+ return 400, {"ok": False, "error": str(exc)}
794
+ except ShoppingCliError as exc:
795
+ return 400, {"ok": False, "error": str(exc)}
796
+ except Exception:
797
+ logger.exception("unhandled error handling %s %s", method, path)
798
+ return 500, {"ok": False, "error": "internal server error"}
799
+
800
+
801
+ def create_app(db_path: str | Path = "shopping-cli.sqlite") -> Any:
802
+ from shopping_cli.api.route_registry import route_info
803
+
804
+ routes = route_info()
805
+ if FastAPI is None:
806
+ return MarketplaceASGIApp(
807
+ db_path,
808
+ route_provider=lambda: routes,
809
+ route_resolver=lambda method, path: resolve_route(method, path, routes=routes),
810
+ )
811
+
812
+ app = FastAPI(
813
+ title="shopping-cli Marketplace API",
814
+ version=VERSION,
815
+ docs_url=None,
816
+ redoc_url=None,
817
+ openapi_url=None,
818
+ )
819
+ app.state.db_path = str(db_path)
820
+ app.state.fastapi_available = True
821
+
822
+ if hasattr(app, "add_middleware"):
823
+ app.add_middleware(_RequestBodyLimitMiddleware)
824
+
825
+ @app.exception_handler(AuthError)
826
+ def auth_error_handler(_request: Any, exc: AuthError) -> Any:
827
+ return _json_error_response(403, str(exc))
828
+
829
+ @app.exception_handler(PermissionDenied)
830
+ def permission_denied_handler(_request: Any, exc: PermissionDenied) -> Any:
831
+ return _json_error_response(403, str(exc))
832
+
833
+ @app.exception_handler(IdempotencyConflict)
834
+ def idempotency_conflict_handler(_request: Any, exc: IdempotencyConflict) -> Any:
835
+ return _json_error_response(409, str(exc))
836
+
837
+ @app.exception_handler(ConflictError)
838
+ def conflict_error_handler(_request: Any, exc: ConflictError) -> Any:
839
+ return _json_error_response(409, str(exc))
840
+
841
+ @app.exception_handler(NotFoundError)
842
+ def not_found_error_handler(_request: Any, exc: NotFoundError) -> Any:
843
+ return _json_error_response(404, str(exc))
844
+
845
+ @app.exception_handler(RateLimitError)
846
+ def rate_limit_error_handler(_request: Any, exc: RateLimitError) -> Any:
847
+ return _json_error_response(429, str(exc))
848
+
849
+ @app.exception_handler(PayloadTooLargeError)
850
+ def payload_too_large_handler(_request: Any, exc: PayloadTooLargeError) -> Any:
851
+ return _json_error_response(413, str(exc))
852
+
853
+ @app.exception_handler(MethodNotAllowedError)
854
+ def method_not_allowed_handler(_request: Any, exc: MethodNotAllowedError) -> Any:
855
+ return _json_error_response(405, str(exc))
856
+
857
+ @app.exception_handler(ValidationError)
858
+ def validation_error_handler(_request: Any, exc: ValidationError) -> Any:
859
+ return _json_error_response(400, str(exc))
860
+
861
+ @app.exception_handler(ShoppingCliError)
862
+ def shopping_cli_error_handler(_request: Any, exc: ShoppingCliError) -> Any:
863
+ return _json_error_response(400, str(exc))
864
+
865
+ if RequestValidationError is not None: # pragma: no cover - exercised with fastapi installed
866
+
867
+ @app.exception_handler(RequestValidationError)
868
+ def request_validation_error_handler(request: Any, exc: Exception) -> Any:
869
+ # 不回显 str(exc):包含 schema 内部结构并回显调用方输入。
870
+ logger.warning("request validation failed on %s: %s", getattr(request, "url", "?"), exc)
871
+ return _json_error_response(400, "invalid request body")
872
+
873
+ if StarletteHTTPException is not None: # pragma: no cover - exercised with fastapi installed
874
+
875
+ @app.exception_handler(StarletteHTTPException)
876
+ def http_exception_handler(_request: Any, exc: Any) -> Any:
877
+ status = int(exc.status_code)
878
+ message = "not found" if status == 404 else "method not allowed" if status == 405 else str(exc.detail)
879
+ return _json_error_response(status, message)
880
+
881
+ @app.exception_handler(Exception)
882
+ def unexpected_error_handler(request: Any, exc: Exception) -> Any:
883
+ logger.exception("unhandled error on %s %s", getattr(request, "method", "?"), getattr(request, "url", "?"))
884
+ return _json_error_response(500, "internal server error")
885
+
886
+ @app.get("/health")
887
+ def health() -> dict[str, Any]:
888
+ return _health(db_path)
889
+
890
+ @app.get("/merchants")
891
+ def list_merchants(limit: str = "", offset: str = "") -> dict[str, Any]:
892
+ return _list_merchants(db_path, {"limit": limit, "offset": offset})
893
+
894
+ @app.post("/merchants")
895
+ def create_merchant(
896
+ payload: dict[str, Any],
897
+ authorization: str = AUTHORIZATION_HEADER,
898
+ idempotency_key: str = IDEMPOTENCY_KEY_HEADER,
899
+ ) -> dict[str, Any]:
900
+ return _create_merchant(
901
+ db_path,
902
+ api_auth.payload_with_auth(payload, authorization, idempotency_key),
903
+ )
904
+
905
+ @app.get("/merchants/{merchant_id}")
906
+ def get_merchant(merchant_id: str) -> dict[str, Any]:
907
+ return _get_merchant(db_path, merchant_id)
908
+
909
+ @app.get("/merchants/{merchant_id}/private-config")
910
+ def get_merchant_private_config(
911
+ merchant_id: str,
912
+ authorization: str = AUTHORIZATION_HEADER,
913
+ ) -> dict[str, Any]:
914
+ return _get_merchant_private_config(
915
+ db_path,
916
+ merchant_id,
917
+ api_auth.payload_with_auth({}, authorization),
918
+ )
919
+
920
+ @app.post("/merchants/{merchant_id}/token/rotate")
921
+ def rotate_merchant_token(
922
+ merchant_id: str,
923
+ payload: dict[str, Any],
924
+ authorization: str = AUTHORIZATION_HEADER,
925
+ ) -> dict[str, Any]:
926
+ return _rotate_merchant_token(
927
+ db_path,
928
+ merchant_id,
929
+ api_auth.payload_with_auth(payload, authorization),
930
+ )
931
+
932
+ @app.post("/merchants/{merchant_id}/token/revoke")
933
+ def revoke_merchant_tokens(
934
+ merchant_id: str,
935
+ payload: dict[str, Any],
936
+ authorization: str = AUTHORIZATION_HEADER,
937
+ ) -> dict[str, Any]:
938
+ return _revoke_merchant_tokens(
939
+ db_path,
940
+ merchant_id,
941
+ api_auth.payload_with_auth(payload, authorization),
942
+ )
943
+
944
+ @app.post("/merchants/{merchant_id}/token/recover")
945
+ def recover_merchant_token(
946
+ merchant_id: str,
947
+ payload: dict[str, Any],
948
+ authorization: str = AUTHORIZATION_HEADER,
949
+ ) -> dict[str, Any]:
950
+ return _recover_merchant_token(
951
+ db_path,
952
+ merchant_id,
953
+ api_auth.payload_with_auth(payload, authorization),
954
+ )
955
+
956
+ @app.patch("/merchants/{merchant_id}")
957
+ def update_merchant(
958
+ merchant_id: str,
959
+ payload: dict[str, Any],
960
+ authorization: str = AUTHORIZATION_HEADER,
961
+ ) -> dict[str, Any]:
962
+ return _update_merchant(db_path, merchant_id, api_auth.payload_with_auth(payload, authorization))
963
+
964
+ @app.post("/products")
965
+ def create_product(payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
966
+ return _create_product(db_path, api_auth.payload_with_auth(payload, authorization))
967
+
968
+ @app.get("/products/{sku}")
969
+ def get_product(sku: str) -> dict[str, Any]:
970
+ return _get_product(db_path, sku)
971
+
972
+ @app.patch("/products/{sku}")
973
+ def update_product(
974
+ sku: str,
975
+ payload: dict[str, Any],
976
+ authorization: str = AUTHORIZATION_HEADER,
977
+ ) -> dict[str, Any]:
978
+ return _update_product(db_path, sku, api_auth.payload_with_auth(payload, authorization))
979
+
980
+ @app.get("/v1/merchant/listings/projections")
981
+ def merchant_listing_projections(merchant_id: str = "") -> dict[str, Any]:
982
+ return _list_listing_projections(db_path, {"merchant_id": merchant_id})
983
+
984
+ @app.get("/v1/merchant/listings/{sku}/projection")
985
+ def merchant_listing_projection(sku: str) -> dict[str, Any]:
986
+ return _get_listing_projection(db_path, sku)
987
+
988
+ @app.post("/v1/merchant/erp/sync")
989
+ def merchant_erp_sync(
990
+ payload: dict[str, Any],
991
+ authorization: str = AUTHORIZATION_HEADER,
992
+ ) -> dict[str, Any]:
993
+ return _sync_erp(db_path, api_auth.payload_with_auth(payload, authorization))
994
+
995
+ @app.get("/search/products")
996
+ def search_products(
997
+ query: str = "",
998
+ city: str = "",
999
+ area: str = "",
1000
+ max_price: str = "",
1001
+ include_out_of_stock: str = "",
1002
+ limit: str = "",
1003
+ offset: str = "",
1004
+ ) -> dict[str, Any]:
1005
+ return _search_products(
1006
+ db_path,
1007
+ {
1008
+ "query": query,
1009
+ "city": city,
1010
+ "area": area,
1011
+ "max_price": max_price,
1012
+ "include_out_of_stock": include_out_of_stock,
1013
+ "limit": limit,
1014
+ "offset": offset,
1015
+ },
1016
+ )
1017
+
1018
+ @app.get("/search/merchants")
1019
+ def search_merchants(query: str = "", city: str = "", limit: str = "", offset: str = "") -> dict[str, Any]:
1020
+ return _search_merchants(db_path, {"query": query, "city": city, "limit": limit, "offset": offset})
1021
+
1022
+ @app.post("/channels/messages")
1023
+ def ingest_channel_message(payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1024
+ return _ingest_channel_message(db_path, api_auth.payload_with_auth(payload, authorization))
1025
+
1026
+ @app.post("/buyer/ask")
1027
+ def buyer_ask(
1028
+ payload: dict[str, Any],
1029
+ authorization: str = AUTHORIZATION_HEADER,
1030
+ idempotency_key: str = IDEMPOTENCY_KEY_HEADER,
1031
+ ) -> dict[str, Any]:
1032
+ return _buyer_ask(db_path, api_auth.payload_with_auth(payload, authorization, idempotency_key))
1033
+
1034
+ @app.post("/conversations")
1035
+ def create_conversation(
1036
+ payload: dict[str, Any],
1037
+ authorization: str = AUTHORIZATION_HEADER,
1038
+ idempotency_key: str = IDEMPOTENCY_KEY_HEADER,
1039
+ ) -> dict[str, Any]:
1040
+ return _create_conversation(db_path, api_auth.payload_with_auth(payload, authorization, idempotency_key))
1041
+
1042
+ @app.get("/buyers/{buyer_id}/conversations")
1043
+ def get_buyer_conversations(
1044
+ buyer_id: str,
1045
+ status: str = "",
1046
+ merchant_id: str = "",
1047
+ sku: str = "",
1048
+ updated_since: str = "",
1049
+ authorization: str = AUTHORIZATION_HEADER,
1050
+ limit: str = "",
1051
+ offset: str = "",
1052
+ include: str = "",
1053
+ ) -> dict[str, Any]:
1054
+ return _conversation_list(
1055
+ db_path,
1056
+ {
1057
+ "buyer_id": buyer_id,
1058
+ "status": status,
1059
+ "merchant_id": merchant_id,
1060
+ "sku": sku,
1061
+ "updated_since": updated_since,
1062
+ "limit": limit,
1063
+ "offset": offset,
1064
+ "include": include,
1065
+ },
1066
+ api_auth.payload_with_auth({}, authorization),
1067
+ owner_kind="buyer",
1068
+ owner_id=buyer_id,
1069
+ )
1070
+
1071
+ @app.get("/conversations/{conversation_id}")
1072
+ def get_conversation(conversation_id: str, authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1073
+ return _get_conversation(db_path, conversation_id, api_auth.payload_with_auth({}, authorization))
1074
+
1075
+ @app.post("/conversations/{conversation_id}/messages")
1076
+ def add_message(
1077
+ conversation_id: str,
1078
+ payload: dict[str, Any],
1079
+ authorization: str = AUTHORIZATION_HEADER,
1080
+ ) -> dict[str, Any]:
1081
+ return _append_conversation_message(
1082
+ db_path, conversation_id, api_auth.payload_with_auth(payload, authorization)
1083
+ )
1084
+
1085
+ @app.post("/conversations/{conversation_id}/close")
1086
+ def close_conversation(
1087
+ conversation_id: str,
1088
+ payload: dict[str, Any],
1089
+ authorization: str = AUTHORIZATION_HEADER,
1090
+ ) -> dict[str, Any]:
1091
+ return _close_conversation(db_path, conversation_id, api_auth.payload_with_auth(payload, authorization))
1092
+
1093
+ @app.post("/agents/heartbeat")
1094
+ def agent_heartbeat(payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1095
+ return _agent_heartbeat(db_path, api_auth.payload_with_auth(payload, authorization))
1096
+
1097
+ @app.post("/agents/tokens")
1098
+ def create_agent_token(payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1099
+ return _create_agent_token(db_path, api_auth.payload_with_auth(payload, authorization))
1100
+
1101
+ @app.get("/agents/tokens")
1102
+ def list_agent_tokens(
1103
+ merchant_id: str = "",
1104
+ limit: str = "",
1105
+ offset: str = "",
1106
+ authorization: str = AUTHORIZATION_HEADER,
1107
+ ) -> dict[str, Any]:
1108
+ return _list_agent_tokens(
1109
+ db_path,
1110
+ api_auth.payload_with_auth({}, authorization),
1111
+ merchant_id=merchant_id,
1112
+ limit=limit,
1113
+ offset=offset,
1114
+ )
1115
+
1116
+ @app.post("/agents/tokens/revoke")
1117
+ def revoke_agent_token(payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1118
+ return _revoke_agent_token(db_path, api_auth.payload_with_auth(payload, authorization))
1119
+
1120
+ @app.post("/agents/tokens/rotate")
1121
+ def rotate_agent_token(payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1122
+ return _rotate_agent_token(db_path, api_auth.payload_with_auth(payload, authorization))
1123
+
1124
+ @app.post("/agents/messages/claim")
1125
+ def claim_agent_message_route(
1126
+ payload: dict[str, Any],
1127
+ authorization: str = AUTHORIZATION_HEADER,
1128
+ ) -> dict[str, Any]:
1129
+ return _claim_agent_message(db_path, api_auth.payload_with_auth(payload, authorization))
1130
+
1131
+ @app.post("/agents/messages/complete")
1132
+ def complete_agent_message_route(
1133
+ payload: dict[str, Any],
1134
+ authorization: str = AUTHORIZATION_HEADER,
1135
+ ) -> dict[str, Any]:
1136
+ return _complete_agent_message(db_path, api_auth.payload_with_auth(payload, authorization))
1137
+
1138
+ @app.post("/agents/messages/fail")
1139
+ def fail_agent_message_route(
1140
+ payload: dict[str, Any],
1141
+ authorization: str = AUTHORIZATION_HEADER,
1142
+ ) -> dict[str, Any]:
1143
+ return _fail_agent_message(db_path, api_auth.payload_with_auth(payload, authorization))
1144
+
1145
+ @app.post("/agents/messages/abandon")
1146
+ def abandon_agent_message_route(
1147
+ payload: dict[str, Any],
1148
+ authorization: str = AUTHORIZATION_HEADER,
1149
+ ) -> dict[str, Any]:
1150
+ return _abandon_agent_message(db_path, api_auth.payload_with_auth(payload, authorization))
1151
+
1152
+ @app.post("/agents/messages/abandon-stale")
1153
+ def abandon_stale_agent_messages_route(
1154
+ payload: dict[str, Any],
1155
+ authorization: str = AUTHORIZATION_HEADER,
1156
+ ) -> dict[str, Any]:
1157
+ return _abandon_stale_agent_messages(db_path, api_auth.payload_with_auth(payload, authorization))
1158
+
1159
+ @app.get("/agents")
1160
+ def list_agents(
1161
+ authorization: str = AUTHORIZATION_HEADER,
1162
+ limit: str = "",
1163
+ offset: str = "",
1164
+ ) -> dict[str, Any]:
1165
+ return _list_agents(db_path, api_auth.payload_with_auth({}, authorization), limit=limit, offset=offset)
1166
+
1167
+ @app.get("/agents/{agent_id}")
1168
+ def get_agent(agent_id: str, authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1169
+ return _get_agent(db_path, agent_id, api_auth.payload_with_auth({}, authorization))
1170
+
1171
+ @app.get("/merchants/{merchant_id}/agents")
1172
+ def get_merchant_agents(
1173
+ merchant_id: str,
1174
+ authorization: str = AUTHORIZATION_HEADER,
1175
+ limit: str = "",
1176
+ offset: str = "",
1177
+ ) -> dict[str, Any]:
1178
+ return _list_agents(
1179
+ db_path,
1180
+ api_auth.payload_with_auth({}, authorization),
1181
+ owner_id=merchant_id,
1182
+ limit=limit,
1183
+ offset=offset,
1184
+ )
1185
+
1186
+ @app.post("/audit/tool-calls")
1187
+ def record_tool_call_audit(payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1188
+ return _record_tool_call_audit(db_path, api_auth.payload_with_auth(payload, authorization))
1189
+
1190
+ @app.get("/audit/events")
1191
+ def get_audit_events(
1192
+ merchant_id: str = "",
1193
+ event: str = "",
1194
+ limit: str = "",
1195
+ offset: str = "",
1196
+ authorization: str = AUTHORIZATION_HEADER,
1197
+ ) -> dict[str, Any]:
1198
+ return _audit_events(
1199
+ db_path,
1200
+ api_auth.payload_with_auth({}, authorization),
1201
+ merchant_id=merchant_id,
1202
+ event=event,
1203
+ limit=limit,
1204
+ offset=offset,
1205
+ )
1206
+
1207
+ @app.get("/human-review/queue")
1208
+ def human_review_queue(
1209
+ merchant_id: str = "",
1210
+ limit: str = "",
1211
+ offset: str = "",
1212
+ authorization: str = AUTHORIZATION_HEADER,
1213
+ ) -> dict[str, Any]:
1214
+ return _human_review_queue(
1215
+ db_path,
1216
+ api_auth.payload_with_auth({}, authorization),
1217
+ merchant_id=merchant_id,
1218
+ limit=limit,
1219
+ offset=offset,
1220
+ )
1221
+
1222
+ @app.get("/human-review/{review_id}")
1223
+ def get_human_review(review_id: str, authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1224
+ return _get_human_review(db_path, review_id, api_auth.payload_with_auth({}, authorization))
1225
+
1226
+ @app.post("/human-review/{review_id}/resolve")
1227
+ def resolve_human_review_item(
1228
+ review_id: str,
1229
+ payload: dict[str, Any],
1230
+ authorization: str = AUTHORIZATION_HEADER,
1231
+ ) -> dict[str, Any]:
1232
+ return _resolve_human_review_item(db_path, review_id, api_auth.payload_with_auth(payload, authorization))
1233
+
1234
+ @app.get("/merchants/{merchant_id}/conversations")
1235
+ def get_merchant_conversations(
1236
+ merchant_id: str,
1237
+ status: str = "",
1238
+ buyer_id: str = "",
1239
+ sku: str = "",
1240
+ updated_since: str = "",
1241
+ authorization: str = AUTHORIZATION_HEADER,
1242
+ limit: str = "",
1243
+ offset: str = "",
1244
+ include: str = "",
1245
+ ) -> dict[str, Any]:
1246
+ return _conversation_list(
1247
+ db_path,
1248
+ {
1249
+ "merchant_id": merchant_id,
1250
+ "status": status,
1251
+ "buyer_id": buyer_id,
1252
+ "sku": sku,
1253
+ "updated_since": updated_since,
1254
+ "limit": limit,
1255
+ "offset": offset,
1256
+ "include": include,
1257
+ },
1258
+ api_auth.payload_with_auth({}, authorization),
1259
+ owner_kind="merchant",
1260
+ owner_id=merchant_id,
1261
+ )
1262
+
1263
+ @app.get("/merchants/{merchant_id}/human-review")
1264
+ def human_review(
1265
+ merchant_id: str,
1266
+ limit: str = "",
1267
+ offset: str = "",
1268
+ authorization: str = AUTHORIZATION_HEADER,
1269
+ include: str = "",
1270
+ ) -> dict[str, Any]:
1271
+ return _merchant_conversations(
1272
+ db_path,
1273
+ merchant_id,
1274
+ api_auth.payload_with_auth({}, authorization),
1275
+ status="human_required",
1276
+ limit=limit,
1277
+ offset=offset,
1278
+ include=include,
1279
+ )
1280
+
1281
+ @app.post("/conversations/{conversation_id}/human-review")
1282
+ def create_human_review(
1283
+ conversation_id: str,
1284
+ payload: dict[str, Any],
1285
+ authorization: str = AUTHORIZATION_HEADER,
1286
+ ) -> dict[str, Any]:
1287
+ return _create_human_review(db_path, conversation_id, api_auth.payload_with_auth(payload, authorization))
1288
+
1289
+ @app.post("/conversations/{conversation_id}/human-review/resolve")
1290
+ def resolve_human_review(
1291
+ conversation_id: str,
1292
+ payload: dict[str, Any],
1293
+ authorization: str = AUTHORIZATION_HEADER,
1294
+ ) -> dict[str, Any]:
1295
+ return _resolve_human_review(db_path, conversation_id, api_auth.payload_with_auth(payload, authorization))
1296
+
1297
+ @app.get("/capabilities")
1298
+ def capabilities() -> dict[str, Any]:
1299
+ return _capabilities(db_path)
1300
+
1301
+ @app.get("/negotiation/pending-messages")
1302
+ def negotiation_pending_messages(authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1303
+ return _negotiation_pending_messages(db_path, api_auth.payload_with_auth({}, authorization))
1304
+
1305
+ @app.post("/negotiation/claims")
1306
+ def negotiation_claim(payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1307
+ return _negotiation_claim(db_path, api_auth.payload_with_auth(payload, authorization))
1308
+
1309
+ @app.post("/negotiation/claims/complete")
1310
+ def negotiation_complete_claim(
1311
+ payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER
1312
+ ) -> dict[str, Any]:
1313
+ return _negotiation_complete_claim(db_path, api_auth.payload_with_auth(payload, authorization))
1314
+
1315
+ @app.post("/negotiation/claims/fail")
1316
+ def negotiation_fail_claim(payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1317
+ return _negotiation_fail_claim(db_path, api_auth.payload_with_auth(payload, authorization))
1318
+
1319
+ @app.post("/negotiation/claims/abandon")
1320
+ def negotiation_abandon_claim(payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER) -> dict[str, Any]:
1321
+ return _negotiation_abandon_claim(db_path, api_auth.payload_with_auth(payload, authorization))
1322
+
1323
+ @app.post("/negotiation/claims/heartbeat")
1324
+ def negotiation_heartbeat_claims(
1325
+ payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER
1326
+ ) -> dict[str, Any]:
1327
+ return _negotiation_heartbeat_claims(db_path, api_auth.payload_with_auth(payload, authorization))
1328
+
1329
+ @app.post("/negotiation/claims/abandon-stale")
1330
+ def negotiation_abandon_stale_claims(
1331
+ payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER
1332
+ ) -> dict[str, Any]:
1333
+ return _negotiation_abandon_stale_claims(db_path, api_auth.payload_with_auth(payload, authorization))
1334
+
1335
+ @app.get("/negotiation/snapshot")
1336
+ def negotiation_snapshot(
1337
+ conversation_id: str = "",
1338
+ message_id: str = "",
1339
+ authorization: str = AUTHORIZATION_HEADER,
1340
+ ) -> dict[str, Any]:
1341
+ return _negotiation_snapshot(
1342
+ db_path,
1343
+ api_auth.payload_with_auth({}, authorization),
1344
+ {"conversation_id": conversation_id, "message_id": message_id},
1345
+ )
1346
+
1347
+ @app.post("/negotiation/decisions")
1348
+ def negotiation_submit_decision(
1349
+ payload: dict[str, Any], authorization: str = AUTHORIZATION_HEADER
1350
+ ) -> dict[str, Any]:
1351
+ return _negotiation_submit_decision(db_path, api_auth.payload_with_auth(payload, authorization))
1352
+
1353
+ return app