lambda-erp 1.3.1__tar.gz → 1.3.3__tar.gz

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 (118) hide show
  1. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/PKG-INFO +6 -4
  2. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/README.md +5 -3
  3. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/chat.py +42 -20
  4. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/erp_context.py +2 -0
  5. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/main.py +2 -0
  6. lambda_erp-1.3.3/api/menu_layout.py +206 -0
  7. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/providers.py +18 -40
  8. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/mcp.py +1 -1
  9. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/pyproject.toml +1 -1
  10. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/.gitignore +0 -0
  11. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/LICENSE +0 -0
  12. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/__init__.py +0 -0
  13. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/attachments.py +0 -0
  14. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/auth.py +0 -0
  15. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/bootstrap.py +0 -0
  16. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/chat_links.py +0 -0
  17. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/demo_limits.py +0 -0
  18. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/deps.py +0 -0
  19. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/errors.py +0 -0
  20. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/list_values.py +0 -0
  21. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/mcp_oauth.py +0 -0
  22. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/mentions.py +0 -0
  23. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/oauth.py +0 -0
  24. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/pdf.py +0 -0
  25. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/pdf_contract.py +0 -0
  26. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/pdf_exports.py +0 -0
  27. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/pdf_profiles.py +0 -0
  28. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/record_notes.py +0 -0
  29. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/remarks_md.py +0 -0
  30. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/__init__.py +0 -0
  31. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/accounting.py +0 -0
  32. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/actions.py +0 -0
  33. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/admin.py +0 -0
  34. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/analytics.py +0 -0
  35. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/availability.py +0 -0
  36. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/bank_reconciliation.py +0 -0
  37. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/bank_statements.py +0 -0
  38. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/chat_api.py +0 -0
  39. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/documents.py +0 -0
  40. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/masters.py +0 -0
  41. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/proposals.py +0 -0
  42. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/reports.py +0 -0
  43. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/setup.py +0 -0
  44. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/services.py +0 -0
  45. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/templates/_proposal_totals.html +0 -0
  46. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/templates/_record_base.html +0 -0
  47. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/templates/document.html +0 -0
  48. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/templates/proposal.html +0 -0
  49. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/templates/record.html +0 -0
  50. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/time_filters.py +0 -0
  51. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/tool_permissions.py +0 -0
  52. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/write_context.py +0 -0
  53. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/docs/agents/README.md +0 -0
  54. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/frontend/LICENSE +0 -0
  55. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/frontend/README.md +0 -0
  56. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/frontend/src/api/client.ts +0 -0
  57. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/__init__.py +0 -0
  58. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/__init__.py +0 -0
  59. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/bank_account.py +0 -0
  60. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  61. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/bank_statement_import.py +0 -0
  62. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/bank_transaction.py +0 -0
  63. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/budget.py +0 -0
  64. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/camt.py +0 -0
  65. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  66. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/general_ledger.py +0 -0
  67. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/journal_entry.py +0 -0
  68. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/payment_entry.py +0 -0
  69. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/pos_invoice.py +0 -0
  70. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/purchase_invoice.py +0 -0
  71. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/revaluation.py +0 -0
  72. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/sales_invoice.py +0 -0
  73. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/settlement.py +0 -0
  74. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/__init__.py +0 -0
  75. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/engine.py +0 -0
  76. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/pack.py +0 -0
  77. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  78. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  79. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  80. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  81. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  82. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  83. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/profiles.py +0 -0
  84. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/spine.py +0 -0
  85. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/subscription.py +0 -0
  86. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/assets/__init__.py +0 -0
  87. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/assets/asset.py +0 -0
  88. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/assets/lifecycle.py +0 -0
  89. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/assets/reservation.py +0 -0
  90. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/buying/__init__.py +0 -0
  91. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/buying/purchase_order.py +0 -0
  92. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/__init__.py +0 -0
  93. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/currency.py +0 -0
  94. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/defaults.py +0 -0
  95. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/item_price.py +0 -0
  96. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/item_prices.py +0 -0
  97. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/pricing_rule.py +0 -0
  98. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  99. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/database.py +0 -0
  100. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/exceptions.py +0 -0
  101. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/hooks.py +0 -0
  102. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/master_integrity.py +0 -0
  103. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/model.py +0 -0
  104. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/selling/__init__.py +0 -0
  105. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/selling/proposal.py +0 -0
  106. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/selling/quotation.py +0 -0
  107. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/selling/sales_order.py +0 -0
  108. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/simulation.py +0 -0
  109. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/stock/__init__.py +0 -0
  110. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/stock/delivery_note.py +0 -0
  111. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/stock/purchase_receipt.py +0 -0
  112. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/stock/stock_entry.py +0 -0
  113. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/stock/stock_ledger.py +0 -0
  114. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/timestamps.py +0 -0
  115. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/utils.py +0 -0
  116. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/validation.py +0 -0
  117. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/workflow.py +0 -0
  118. {lambda_erp-1.3.1 → lambda_erp-1.3.3}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.3.1
3
+ Version: 1.3.3
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -261,15 +261,17 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
261
261
 
262
262
  ```
263
263
  OPENAI_API_KEY=sk-...
264
+ LAMBDA_ERP_CHAT_MODEL=gpt-6.1-sol # optional chat override; default shown
264
265
  LAMBDA_ERP_REPORT_MODEL=gpt-6.1-sol # optional specialist override; default shown
266
+ LAMBDA_ERP_TITLE_MODEL=gpt-6-luna # optional chat-title override; default shown
265
267
  LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
266
268
  LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
267
269
  LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
268
270
  ```
269
271
 
270
- Chat and custom-report specification generation share `OPENAI_API_KEY`. Both
271
- the orchestrator and report specialist default to GPT-5.6 Terra; the specialist
272
- can be overridden with `LAMBDA_ERP_REPORT_MODEL`.
272
+ Chat, chat titles and custom-report specification generation share
273
+ `OPENAI_API_KEY`. The chat and the report specialist default to GPT-6.1 Sol,
274
+ titles to GPT-6 Luna; see [docs/llm-pricing.md](docs/llm-pricing.md) for prices.
273
275
 
274
276
  **Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
275
277
 
@@ -233,15 +233,17 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
233
233
 
234
234
  ```
235
235
  OPENAI_API_KEY=sk-...
236
+ LAMBDA_ERP_CHAT_MODEL=gpt-6.1-sol # optional chat override; default shown
236
237
  LAMBDA_ERP_REPORT_MODEL=gpt-6.1-sol # optional specialist override; default shown
238
+ LAMBDA_ERP_TITLE_MODEL=gpt-6-luna # optional chat-title override; default shown
237
239
  LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
238
240
  LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
239
241
  LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
240
242
  ```
241
243
 
242
- Chat and custom-report specification generation share `OPENAI_API_KEY`. Both
243
- the orchestrator and report specialist default to GPT-5.6 Terra; the specialist
244
- can be overridden with `LAMBDA_ERP_REPORT_MODEL`.
244
+ Chat, chat titles and custom-report specification generation share
245
+ `OPENAI_API_KEY`. The chat and the report specialist default to GPT-6.1 Sol,
246
+ titles to GPT-6 Luna; see [docs/llm-pricing.md](docs/llm-pricing.md) for prices.
245
247
 
246
248
  **Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
247
249
 
@@ -3283,6 +3283,15 @@ def _generate_report_spec_via_openai(
3283
3283
  # ---------------------------------------------------------------------------
3284
3284
 
3285
3285
 
3286
+ # Small and cheap: a 3-6 word label. gpt-4.1-nano was retired by OpenAI and
3287
+ # every call failed silently, leaving all chats as "New Chat".
3288
+ TITLE_MODEL = "gpt-6-luna"
3289
+
3290
+
3291
+ def _title_model() -> str:
3292
+ return os.environ.get("LAMBDA_ERP_TITLE_MODEL") or TITLE_MODEL
3293
+
3294
+
3286
3295
  async def generate_title(
3287
3296
  session_id: str,
3288
3297
  user_message: str,
@@ -3299,12 +3308,12 @@ async def generate_title(
3299
3308
  if not api_key or api_key == "sk-your-key-here":
3300
3309
  return
3301
3310
 
3311
+ title_model = _title_model()
3302
3312
  try:
3303
3313
  client = OpenAI(
3304
3314
  api_key=api_key,
3305
3315
  timeout=httpx.Timeout(30.0, connect=5.0),
3306
3316
  )
3307
- title_model = "gpt-4.1-nano"
3308
3317
  reservation_id = None
3309
3318
  if is_demo_role(user_role):
3310
3319
  _blocked, reservation_id = demo_limiter.reserve(
@@ -3317,14 +3326,15 @@ async def generate_title(
3317
3326
 
3318
3327
  settled = False
3319
3328
  try:
3329
+ # Responses API with low effort: GPT-6 models reason, and the
3330
+ # output budget must leave room for that before the title itself.
3320
3331
  response = await asyncio.to_thread(
3321
- client.chat.completions.create,
3332
+ client.responses.create,
3322
3333
  model=title_model,
3323
- messages=[
3324
- {"role": "system", "content": "Generate a very short title (3-6 words, no quotes) for this chat based on the first exchange. Just the title, nothing else."},
3325
- {"role": "user", "content": f"User: {user_message[:200]}\nAssistant: {assistant_message[:200]}"},
3326
- ],
3327
- max_completion_tokens=30,
3334
+ instructions="Generate a very short title (3-6 words, no quotes) for this chat based on the first exchange. Just the title, nothing else.",
3335
+ input=f"User: {user_message[:200]}\nAssistant: {assistant_message[:200]}",
3336
+ max_output_tokens=400,
3337
+ reasoning={"effort": "low"},
3328
3338
  )
3329
3339
  usage = getattr(response, "usage", None)
3330
3340
  demo_limiter.settle(
@@ -3334,20 +3344,25 @@ async def generate_title(
3334
3344
  role=user_role,
3335
3345
  provider="openai",
3336
3346
  model=title_model,
3337
- prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0) if usage else 0,
3338
- completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0) if usage else 0,
3347
+ prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0) if usage else 0,
3348
+ completion_tokens=int(getattr(usage, "output_tokens", 0) or 0) if usage else 0,
3339
3349
  session_id=session_id,
3340
3350
  )
3341
3351
  settled = True
3342
3352
 
3343
- title = response.choices[0].message.content.strip().strip('"\'')
3353
+ title = (response.output_text or "").strip().strip('"\'')[:120]
3344
3354
  if title:
3345
3355
  update_session_title(session_id, title)
3356
+ else:
3357
+ logger.warning("[chat_title] model=%s returned no title (status=%s)",
3358
+ title_model, getattr(response, "status", None))
3346
3359
  finally:
3347
3360
  if not settled:
3348
3361
  demo_limiter.release(reservation_id)
3349
- except Exception:
3350
- pass # Title generation is best-effort
3362
+ except Exception as exc:
3363
+ # Best-effort, but never silent: a retired model once left every chat
3364
+ # untitled without a single log line.
3365
+ logger.warning("[chat_title] model=%s failed: %s", title_model, exc)
3351
3366
 
3352
3367
 
3353
3368
  # ---------------------------------------------------------------------------
@@ -3356,9 +3371,15 @@ async def generate_title(
3356
3371
 
3357
3372
  # GPT-6.1 Sol requires Responses for function tools and does not support
3358
3373
  # reasoning effort "none", including tool-free wrap-ups after the step limit.
3374
+ # LAMBDA_ERP_CHAT_MODEL overrides it per deployment (e.g. to trial a cheaper
3375
+ # model on dev); see docs/llm-pricing.md.
3359
3376
  ORCHESTRATOR_MODEL = "gpt-6.1-sol"
3360
3377
 
3361
3378
 
3379
+ def _chat_model() -> str:
3380
+ return os.environ.get("LAMBDA_ERP_CHAT_MODEL") or ORCHESTRATOR_MODEL
3381
+
3382
+
3362
3383
  # ---------------------------------------------------------------------------
3363
3384
  # LLM API selector: Responses API (default) vs Chat Completions
3364
3385
  #
@@ -3373,8 +3394,9 @@ ORCHESTRATOR_MODEL = "gpt-6.1-sol"
3373
3394
  # ---------------------------------------------------------------------------
3374
3395
 
3375
3396
  def _use_responses_api() -> bool:
3376
- # A legacy deployment flag must not route Sol 6.1 into an unsupported API.
3377
- if ORCHESTRATOR_MODEL == "gpt-6.1-sol":
3397
+ # A legacy deployment flag must not route a GPT-6.1 model into an
3398
+ # unsupported API.
3399
+ if _chat_model().startswith("gpt-6.1-"):
3378
3400
  return True
3379
3401
  # Default: the Responses API (needed for Office attachments + native
3380
3402
  # reasoning). Set ERP_CHAT_API=chat to fall back to Chat Completions.
@@ -3535,12 +3557,12 @@ def _shim_usage_from_responses(usage):
3535
3557
 
3536
3558
  def _orchestrator_turn(client, messages, tools, max_tokens, *, tool_choice="auto"):
3537
3559
  """One orchestrator LLM turn. Returns (message, usage) shaped like Chat
3538
- Completions. Sol 6.1 always uses Responses; compatible models honor ERP_CHAT_API.
3560
+ Completions. GPT-6.1 models always use Responses; others honor ERP_CHAT_API.
3539
3561
  Blocking — call via asyncio.to_thread."""
3540
3562
  if _use_responses_api():
3541
3563
  instructions, input_items = _responses_request_from_chat(messages)
3542
3564
  resp = client.responses.create(
3543
- model=ORCHESTRATOR_MODEL,
3565
+ model=_chat_model(),
3544
3566
  instructions=instructions,
3545
3567
  input=input_items,
3546
3568
  tools=_responses_tools_from_chat(tools),
@@ -3550,7 +3572,7 @@ def _orchestrator_turn(client, messages, tools, max_tokens, *, tool_choice="auto
3550
3572
  )
3551
3573
  return _shim_message_from_responses(resp), _shim_usage_from_responses(getattr(resp, "usage", None))
3552
3574
  resp = client.chat.completions.create(
3553
- model=ORCHESTRATOR_MODEL,
3575
+ model=_chat_model(),
3554
3576
  messages=messages,
3555
3577
  tools=tools,
3556
3578
  tool_choice=tool_choice,
@@ -3715,7 +3737,7 @@ async def run_thinking_loop(
3715
3737
  # on ANY exit path — including asyncio.CancelledError raised after
3716
3738
  # the SDK call has already returned but before settle() ran.
3717
3739
  settled = False
3718
- model_name = ORCHESTRATOR_MODEL
3740
+ model_name = _chat_model()
3719
3741
  try:
3720
3742
  print(f"[chat_llm] provider=openai model={model_name} session_id={session_id or '-'} iter={iteration + 1}", flush=True)
3721
3743
  await on_event({"type": "llm_provider", "provider": "openai", "model": model_name})
@@ -3902,9 +3924,9 @@ async def run_thinking_loop(
3902
3924
  tool_choice="none",
3903
3925
  )
3904
3926
  demo_limiter.settle(
3905
- None, actual_cost_usd=cost_of_openai_call(ORCHESTRATOR_MODEL, usage),
3927
+ None, actual_cost_usd=cost_of_openai_call(_chat_model(), usage),
3906
3928
  ip=client_ip or "unknown", role=user_role, provider="openai",
3907
- model=ORCHESTRATOR_MODEL,
3929
+ model=_chat_model(),
3908
3930
  prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0) if usage else 0,
3909
3931
  completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0) if usage else 0,
3910
3932
  session_id=session_id,
@@ -29,6 +29,8 @@ field. Internal team remarks ("customer disputes line 2", "@Anna please check")
29
29
  internal note instead (add_record_note / list_record_notes): never printed, visible to
30
30
  signed-in colleagues only, and @mentions there notify colleagues. Never put internal
31
31
  remarks into `remarks`, and never copy internal notes into a document the customer sees.
32
+ Requests about the user's own sidebar menu ("hide rentals", "put CRM first") use
33
+ get_menu_layout, then update_menu_layout. They change only that user's menu, not access.
32
34
  Generate a fresh PDF after edits. Only a successful artifact descriptor proves generation;
33
35
  download that artifact, check its SHA-256 and deliver it through the calling application's
34
36
  attachment mechanism. Do not invent download URLs or reuse a stale document attachment.
@@ -61,6 +61,8 @@ async def lifespan(app: FastAPI):
61
61
  initialize_mentions()
62
62
  from api.record_notes import initialize_record_notes
63
63
  initialize_record_notes()
64
+ from api.menu_layout import initialize_menu_layout
65
+ initialize_menu_layout()
64
66
 
65
67
  # Load customer extension plugins before anything creates documents.
66
68
  load_plugins()
@@ -0,0 +1,206 @@
1
+ """Personal sidebar menu: hide and reorder groups and items, per user.
2
+
3
+ The menu itself is defined by the frontend (core groups plus whatever plugins
4
+ register), so the web app publishes what it shows to each user as the
5
+ `nav_catalog` preference. Reads and writes here validate against that catalog;
6
+ the layout is stored as the `nav_layout` preference. Hiding only tidies the
7
+ menu: routes and permissions are unchanged.
8
+ """
9
+ import json
10
+
11
+ from pydantic import Field
12
+
13
+ from api import mentions, services
14
+ from lambda_erp.database import get_db
15
+
16
+ CATALOG_KEY = "nav_catalog"
17
+ LAYOUT_KEY = "nav_layout"
18
+ # The page that edits the menu can never be hidden, or a user could lock
19
+ # themselves out of undoing it. Its group therefore can't be hidden either.
20
+ LOCKED_ITEM = "/settings/menu"
21
+ REF = Field(min_length=1, max_length=200)
22
+
23
+
24
+ def _actor(user):
25
+ if (user or {}).get("_shared_connection"):
26
+ raise PermissionError("The menu is personal. The shared team key identifies its owner, "
27
+ "not the current user; connect with a personal ERP account.")
28
+ from api.auth import refresh_auth_principal
29
+ actor = refresh_auth_principal(user)
30
+ if not actor or actor.get("role") not in mentions.REAL_ROLES:
31
+ raise PermissionError("Sign in with a personal ERP account to change your menu.")
32
+ return actor.get("user_id") or actor["name"]
33
+
34
+
35
+ def _read(user_name, key, default):
36
+ rows = get_db().sql('SELECT value FROM "User Preference" WHERE user_name = ? AND key = ?',
37
+ [user_name, key])
38
+ try:
39
+ return json.loads(rows[0]["value"]) if rows else default
40
+ except (TypeError, ValueError):
41
+ return default
42
+
43
+
44
+ def _write(user_name, key, value):
45
+ db, text = get_db(), json.dumps(value, separators=(",", ":"))
46
+ with db.atomic():
47
+ if db.sql('SELECT 1 FROM "User Preference" WHERE user_name = ? AND key = ?', [user_name, key]):
48
+ db.sql('UPDATE "User Preference" SET value = ? WHERE user_name = ? AND key = ?',
49
+ [text, user_name, key])
50
+ else:
51
+ db.sql('INSERT INTO "User Preference" (user_name, key, value) VALUES (?, ?, ?)',
52
+ [user_name, key, text])
53
+
54
+
55
+ def _layout(user_name):
56
+ raw = _read(user_name, LAYOUT_KEY, {})
57
+ raw = raw if isinstance(raw, dict) else {}
58
+ items = raw.get("items") if isinstance(raw.get("items"), dict) else {}
59
+ return {"order": [k for k in raw.get("order") or [] if isinstance(k, str)],
60
+ "items": {g: [k for k in v if isinstance(k, str)] for g, v in items.items() if isinstance(v, list)},
61
+ "hidden": [k for k in raw.get("hidden") or [] if isinstance(k, str)]}
62
+
63
+
64
+ def _catalog(user_name):
65
+ raw = _read(user_name, CATALOG_KEY, [])
66
+ groups = []
67
+ for group in raw if isinstance(raw, list) else []:
68
+ if not isinstance(group, dict) or not isinstance(group.get("key"), str):
69
+ continue
70
+ items = [{"key": i["key"], "label": str(i.get("label") or i["key"]), "title": str(i.get("title") or "")}
71
+ for i in group.get("items") or [] if isinstance(i, dict) and isinstance(i.get("key"), str)]
72
+ groups.append({"key": group["key"], "label": str(group.get("label") or group["key"]),
73
+ "title": str(group.get("title") or ""), "items": items})
74
+ return groups
75
+
76
+
77
+ def _ordered(default, preferred):
78
+ """Keys named in `preferred` first, in that order; the rest keep their default order."""
79
+ first = [k for k in dict.fromkeys(preferred) if k in default]
80
+ return first + [k for k in default if k not in first]
81
+
82
+
83
+ def effective_menu(catalog, layout):
84
+ hidden = set(layout["hidden"])
85
+ by_key = {g["key"]: g for g in catalog}
86
+ menu = []
87
+ for key in _ordered([g["key"] for g in catalog], layout["order"]):
88
+ group = by_key[key]
89
+ items = {i["key"]: i for i in group["items"]}
90
+ menu.append({**{k: group[k] for k in ("key", "label", "title")}, "hidden": key in hidden,
91
+ "items": [{**items[i], "hidden": i in hidden, "locked": i == LOCKED_ITEM}
92
+ for i in _ordered(list(items), layout["items"].get(key, []))],
93
+ "locked": LOCKED_ITEM in items})
94
+ return menu
95
+
96
+
97
+ def _resolver(catalog):
98
+ """Map a reference (group key, item path, or an English or translated label) to a key."""
99
+ exact, named = {}, {}
100
+ for group in catalog:
101
+ exact[group["key"]] = ("group", group["key"], None)
102
+ for item in group["items"]:
103
+ exact[item["key"]] = ("item", item["key"], group["key"])
104
+ for group in catalog:
105
+ refs = [(group, "group", None)] + [(item, "item", group["key"]) for item in group["items"]]
106
+ for entry, kind, parent in refs:
107
+ for name in {entry["label"].casefold(), entry["title"].casefold()} - {""}:
108
+ named.setdefault(name, set()).add((kind, entry["key"], parent))
109
+
110
+ def resolve(ref):
111
+ if ref in exact:
112
+ return exact[ref]
113
+ matches = named.get(ref.strip().casefold(), set())
114
+ if len(matches) == 1:
115
+ return next(iter(matches))
116
+ if matches:
117
+ raise ValueError(f"{ref!r} is ambiguous: use one of the keys "
118
+ f"{', '.join(sorted(m[1] for m in matches))}.")
119
+ raise ValueError(f"Unknown menu entry {ref!r}. Call get_menu_layout for the available "
120
+ "groups and items.")
121
+ return resolve
122
+
123
+
124
+ class GetLayout(mentions.Input):
125
+ pass
126
+
127
+
128
+ class UpdateLayout(mentions.Input):
129
+ hide: list[str] = Field(default_factory=list, max_length=200)
130
+ show: list[str] = Field(default_factory=list, max_length=200)
131
+ group_order: list[str] | None = Field(default=None, max_length=200)
132
+ item_order: dict[str, list[str]] = Field(default_factory=dict, max_length=100)
133
+ reset: bool = False
134
+
135
+
136
+ def _response(catalog, layout, note=None):
137
+ result = {"menu": effective_menu(catalog, layout), "layout": layout}
138
+ if not catalog:
139
+ result["note"] = ("Your menu is not known yet: open the ERP web app once, then try again.")
140
+ if note:
141
+ result["note"] = note
142
+ return result
143
+
144
+
145
+ def get_menu_layout(args, user):
146
+ GetLayout.model_validate(args)
147
+ user_name = _actor(user)
148
+ return _response(_catalog(user_name), _layout(user_name))
149
+
150
+
151
+ def update_menu_layout(args, user):
152
+ data, user_name = UpdateLayout.model_validate(args), _actor(user)
153
+ catalog = _catalog(user_name)
154
+ if not catalog:
155
+ raise ValueError("Your menu is not known yet: open the ERP web app once, then try again.")
156
+ resolve = _resolver(catalog)
157
+ layout = {"order": [], "items": {}, "hidden": []} if data.reset else _layout(user_name)
158
+ hidden = dict.fromkeys(layout["hidden"])
159
+ for ref in data.show:
160
+ hidden.pop(resolve(ref)[1], None)
161
+ locked_group = next((g["key"] for g in catalog if any(i["key"] == LOCKED_ITEM for i in g["items"])), None)
162
+ for ref in data.hide:
163
+ kind, key, _ = resolve(ref)
164
+ if key == LOCKED_ITEM or key == locked_group:
165
+ raise ValueError(f"{ref!r} can't be hidden: it holds the menu settings page. "
166
+ "Hide the other items in it instead.")
167
+ hidden[key] = None
168
+ layout["hidden"] = list(hidden)
169
+ if data.group_order is not None:
170
+ keys = [resolve(ref) for ref in data.group_order]
171
+ if any(kind != "group" for kind, _, _ in keys):
172
+ raise ValueError("group_order takes groups only; reorder items with item_order.")
173
+ layout["order"] = [key for _, key, _ in keys]
174
+ for group_ref, refs in data.item_order.items():
175
+ kind, group, _ = resolve(group_ref)
176
+ if kind != "group":
177
+ raise ValueError(f"item_order keys are groups; {group_ref!r} is an item.")
178
+ keys = [resolve(ref) for ref in refs]
179
+ if any(kind != "item" or parent != group for kind, _, parent in keys):
180
+ raise ValueError(f"item_order[{group_ref!r}] may only list items of that group; "
181
+ "items can't move between groups.")
182
+ layout["items"][group] = [key for _, key, _ in keys]
183
+ _write(user_name, LAYOUT_KEY, layout)
184
+ return _response(catalog, layout)
185
+
186
+
187
+ def initialize_menu_layout():
188
+ services.register_action(
189
+ "get_menu_layout", get_menu_layout, minimum_role="viewer", pass_user=True,
190
+ parameters=GetLayout.model_json_schema(),
191
+ description=(
192
+ "Show the current user's personal sidebar menu: every group and item in display order, "
193
+ "with its key, English label, label in the user's language (title) and whether it is "
194
+ "hidden. Call this before update_menu_layout."))
195
+ services.register_action(
196
+ "update_menu_layout", update_menu_layout, minimum_role="viewer", pass_user=True,
197
+ parameters=UpdateLayout.model_json_schema(),
198
+ description=(
199
+ "Hide, show or reorder entries in the current user's own sidebar menu (no one else's). "
200
+ "Refer to an entry by its key from get_menu_layout (a group label such as 'Rentals', or "
201
+ "an item path such as '/app/asset') or by its label. hide/show take groups or items; "
202
+ "hiding a group hides all of it. group_order lists groups to put first, in that order "
203
+ "(the rest follow); item_order maps a group to its items in the new order. Items stay "
204
+ "in their group. reset=true restores the default menu before applying the rest. The "
205
+ "menu settings entry can't be hidden. Hiding only tidies the menu: pages stay "
206
+ "reachable and permissions are unchanged."))
@@ -1,7 +1,7 @@
1
1
  """Pricing data for external LLM APIs the chat orchestrator uses.
2
2
 
3
- Mirrors the shape of `lambda-web/backend/providers.py`. Includes active models
4
- and GPT-6 alternatives for comparison. Prices are Standard USD per 1M tokens;
3
+ Mirrors the shape of `lambda-web/backend/providers.py`. Lists the OpenAI models
4
+ available to us. Prices are Standard USD per 1M tokens;
5
5
  see docs/llm-pricing.md for sources and billing scope.
6
6
 
7
7
  Consumers import `cost_of_openai_call` to turn an SDK `response.usage` object
@@ -19,60 +19,38 @@ from typing import Any, Optional
19
19
  # ---------------------------------------------------------------------------
20
20
 
21
21
  OPENAI_PRICING: dict[str, dict[str, Any]] = {
22
- # Official model pages, verified 2026-09-29. Above 272K input tokens,
23
- # the higher rates apply to the entire request (including cache hits).
22
+ # The models available to us, Standard tier, verified 2026-10-30. Above
23
+ # 272K input tokens the higher rates apply to the entire request
24
+ # (including cache hits). cache_write is the 30-minute retention rate,
25
+ # cache_write_12h the 12-hour one.
24
26
  "gpt-6-astra": {
25
27
  "tiered": True,
26
28
  "tiers": [
27
- {"max_input_tokens": 272_000, "input": 10.00, "cached_input": 1.00, "cache_write": 12.50, "output": 50.00},
28
- {"max_input_tokens": None, "input": 20.00, "cached_input": 2.00, "cache_write": 25.00, "output": 75.00},
29
- ],
30
- },
31
- "gpt-6-sol": {
32
- "tiered": True,
33
- "tiers": [
34
- {"max_input_tokens": 272_000, "input": 2.00, "cached_input": 0.20, "cache_write": 2.50, "output": 10.00},
35
- {"max_input_tokens": None, "input": 4.00, "cached_input": 0.40, "cache_write": 5.00, "output": 15.00},
36
- ],
37
- },
38
- "gpt-6-luna": {
39
- "tiered": True,
40
- "tiers": [
41
- {"max_input_tokens": 272_000, "input": 0.10, "cached_input": 0.01, "cache_write": 0.125, "output": 0.50},
42
- {"max_input_tokens": None, "input": 0.20, "cached_input": 0.02, "cache_write": 0.25, "output": 0.75},
29
+ {"max_input_tokens": 272_000, "input": 10.00, "cached_input": 1.00, "cache_write": 12.50, "cache_write_12h": 25.00, "output": 50.00},
30
+ {"max_input_tokens": None, "input": 20.00, "cached_input": 2.00, "cache_write": 25.00, "cache_write_12h": 50.00, "output": 75.00},
43
31
  ],
44
32
  },
45
33
  "gpt-6.1-sol": {
46
34
  "tiered": True,
47
35
  "tiers": [
48
- {"max_input_tokens": 272_000, "input": 2.00, "cached_input": 0.10, "cache_write": 2.50, "output": 10.00},
49
- {"max_input_tokens": None, "input": 4.00, "cached_input": 0.20, "cache_write": 5.00, "output": 15.00},
36
+ {"max_input_tokens": 272_000, "input": 2.00, "cached_input": 0.10, "cache_write": 2.50, "cache_write_12h": 5.00, "output": 10.00},
37
+ {"max_input_tokens": None, "input": 4.00, "cached_input": 0.20, "cache_write": 5.00, "cache_write_12h": 10.00, "output": 15.00},
50
38
  ],
51
39
  },
52
- "gpt-5.4": {
40
+ "gpt-6.1-terra": {
53
41
  "tiered": True,
54
42
  "tiers": [
55
- {"max_input_tokens": 272_000, "input": 2.50, "cached_input": 0.25, "output": 15.00},
56
- {"max_input_tokens": None, "input": 5.00, "cached_input": 0.50, "output": 22.50},
43
+ {"max_input_tokens": 272_000, "input": 1.00, "cached_input": 0.05, "cache_write": 1.25, "cache_write_12h": 2.50, "output": 5.00},
44
+ {"max_input_tokens": None, "input": 2.00, "cached_input": 0.10, "cache_write": 2.50, "cache_write_12h": 5.00, "output": 7.50},
57
45
  ],
58
46
  },
59
- # GPT-5.6 Terra — tiered like gpt-5.4 (official pricing: 2x input, 1.5x
60
- # output above 272K input; the 5.6 family also prices cache writes).
61
- "gpt-5.6-terra": {
47
+ "gpt-6-luna": {
62
48
  "tiered": True,
63
49
  "tiers": [
64
- {"max_input_tokens": 272_000, "input": 2.00, "cached_input": 0.20, "cache_write": 2.50, "output": 12.00},
65
- {"max_input_tokens": None, "input": 4.00, "cached_input": 0.40, "cache_write": 5.00, "output": 18.00},
50
+ {"max_input_tokens": 272_000, "input": 0.10, "cached_input": 0.01, "cache_write": 0.125, "cache_write_12h": 0.25, "output": 0.50},
51
+ {"max_input_tokens": None, "input": 0.20, "cached_input": 0.02, "cache_write": 0.25, "cache_write_12h": 0.50, "output": 0.75},
66
52
  ],
67
53
  },
68
- "gpt-4.1-nano": {"input": 0.10, "cached_input": 0.025, "output": 0.40},
69
- "gpt-4.1-mini": {"input": 0.40, "cached_input": 0.10, "output": 1.60},
70
- "gpt-4.1": {"input": 2.00, "cached_input": 0.50, "output": 8.00},
71
- "gpt-5": {"input": 1.25, "cached_input": 0.125, "output": 10.00},
72
- "gpt-5-mini": {"input": 0.25, "cached_input": 0.025, "output": 2.00},
73
- "gpt-5-nano": {"input": 0.05, "cached_input": 0.005, "output": 0.40},
74
- "gpt-4o": {"input": 2.50, "cached_input": 1.25, "output": 10.00},
75
- "gpt-4o-mini": {"input": 0.15, "cached_input": 0.075, "output": 0.60},
76
54
  }
77
55
 
78
56
  # Speech-to-text, priced per minute of audio (not per token).
@@ -84,14 +62,14 @@ TRANSCRIBE_PRICING: dict[str, dict[str, float]] = {
84
62
 
85
63
 
86
64
  # ---------------------------------------------------------------------------
87
- # Rate lookup — preserve the legacy gpt-5.4 estimate for unknown model IDs.
65
+ # Rate lookup — unknown model IDs are estimated at the gpt-6.1-sol rates.
88
66
  # Register a model explicitly before using it; this fallback is not a price cap.
89
67
  # ---------------------------------------------------------------------------
90
68
 
91
69
  def get_openai_rates(model: str, input_tokens: int = 0) -> dict[str, float]:
92
70
  entry = OPENAI_PRICING.get(model)
93
71
  if entry is None:
94
- entry = OPENAI_PRICING["gpt-5.4"]
72
+ entry = OPENAI_PRICING["gpt-6.1-sol"]
95
73
  if entry.get("tiered"):
96
74
  for tier in entry["tiers"]:
97
75
  cap = tier.get("max_input_tokens")
@@ -50,7 +50,7 @@ _EXCLUDE = {
50
50
  }
51
51
 
52
52
  _READ_ONLY = {
53
- 'search_mention_users', 'list_my_mentions', 'list_record_notes',
53
+ 'search_mention_users', 'list_my_mentions', 'list_record_notes', 'get_menu_layout',
54
54
  'get_erp_context',
55
55
  'list_documents', 'get_document_fields', 'get_document', 'get_master_fields',
56
56
  'search_masters', 'get_report', 'get_current_time', 'query_dataset',
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "1.3.1"
3
+ version = "1.3.3"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes