lambda-erp 1.3.0__tar.gz → 1.3.2__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.
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/PKG-INFO +6 -4
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/README.md +5 -3
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/chat.py +45 -20
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/erp_context.py +7 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/main.py +4 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/mentions.py +31 -5
- lambda_erp-1.3.2/api/menu_layout.py +206 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/providers.py +18 -40
- lambda_erp-1.3.2/api/record_notes.py +233 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/masters.py +4 -1
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/mcp.py +1 -1
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/database.py +20 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/pyproject.toml +1 -1
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/.gitignore +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/LICENSE +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/attachments.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/auth.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/bootstrap.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/chat_links.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/demo_limits.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/deps.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/errors.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/list_values.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/oauth.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/pdf.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/pdf_contract.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/pdf_exports.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/remarks_md.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/accounting.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/actions.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/admin.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/analytics.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/availability.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/documents.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/proposals.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/reports.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/routers/setup.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/services.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/templates/document.html +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/templates/proposal.html +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/templates/record.html +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/time_filters.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/tool_permissions.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/api/write_context.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/docs/agents/README.md +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/frontend/LICENSE +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/frontend/README.md +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/model.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.3.0 → lambda_erp-1.3.2}/terraform/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.2
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
|
@@ -2720,6 +2720,9 @@ The `remarks` (Notes / Terms) field on quotations, sales orders, and invoices re
|
|
|
2720
2720
|
- Separate blocks with a blank line; a single newline is just a line break.
|
|
2721
2721
|
Plain text (no special characters) still renders fine, so only reach for the markup when it improves a customer-facing note.
|
|
2722
2722
|
|
|
2723
|
+
### Internal notes (never printed)
|
|
2724
|
+
`remarks` is PRINTED and goes to the customer or supplier. Internal team remarks ("customer disputes line 2", "@Anna please check the price") belong in an internal note on the record instead: `add_record_note` (doctype = the record type slug, e.g. `sales-invoice` or `customer`; record_id = its name), read with `list_record_notes`, change with `update_record_note` / `delete_record_note`. Every document and master type supports them; they appear in the record's "Internal notes" card and never change the record. To notify a colleague, first `search_mention_users` and put their exact `mention_text` in the body. Never put internal remarks into `remarks`, and never copy internal notes into customer-facing text.
|
|
2725
|
+
|
|
2723
2726
|
### Purchase Cycle
|
|
2724
2727
|
Purchase Order → Purchase Receipt (receiving) / Purchase Invoice (billing) → Payment Entry
|
|
2725
2728
|
|
|
@@ -3280,6 +3283,15 @@ def _generate_report_spec_via_openai(
|
|
|
3280
3283
|
# ---------------------------------------------------------------------------
|
|
3281
3284
|
|
|
3282
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
|
+
|
|
3283
3295
|
async def generate_title(
|
|
3284
3296
|
session_id: str,
|
|
3285
3297
|
user_message: str,
|
|
@@ -3296,12 +3308,12 @@ async def generate_title(
|
|
|
3296
3308
|
if not api_key or api_key == "sk-your-key-here":
|
|
3297
3309
|
return
|
|
3298
3310
|
|
|
3311
|
+
title_model = _title_model()
|
|
3299
3312
|
try:
|
|
3300
3313
|
client = OpenAI(
|
|
3301
3314
|
api_key=api_key,
|
|
3302
3315
|
timeout=httpx.Timeout(30.0, connect=5.0),
|
|
3303
3316
|
)
|
|
3304
|
-
title_model = "gpt-4.1-nano"
|
|
3305
3317
|
reservation_id = None
|
|
3306
3318
|
if is_demo_role(user_role):
|
|
3307
3319
|
_blocked, reservation_id = demo_limiter.reserve(
|
|
@@ -3314,14 +3326,15 @@ async def generate_title(
|
|
|
3314
3326
|
|
|
3315
3327
|
settled = False
|
|
3316
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.
|
|
3317
3331
|
response = await asyncio.to_thread(
|
|
3318
|
-
client.
|
|
3332
|
+
client.responses.create,
|
|
3319
3333
|
model=title_model,
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
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"},
|
|
3325
3338
|
)
|
|
3326
3339
|
usage = getattr(response, "usage", None)
|
|
3327
3340
|
demo_limiter.settle(
|
|
@@ -3331,20 +3344,25 @@ async def generate_title(
|
|
|
3331
3344
|
role=user_role,
|
|
3332
3345
|
provider="openai",
|
|
3333
3346
|
model=title_model,
|
|
3334
|
-
prompt_tokens=int(getattr(usage, "
|
|
3335
|
-
completion_tokens=int(getattr(usage, "
|
|
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,
|
|
3336
3349
|
session_id=session_id,
|
|
3337
3350
|
)
|
|
3338
3351
|
settled = True
|
|
3339
3352
|
|
|
3340
|
-
title = response.
|
|
3353
|
+
title = (response.output_text or "").strip().strip('"\'')[:120]
|
|
3341
3354
|
if title:
|
|
3342
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))
|
|
3343
3359
|
finally:
|
|
3344
3360
|
if not settled:
|
|
3345
3361
|
demo_limiter.release(reservation_id)
|
|
3346
|
-
except Exception:
|
|
3347
|
-
|
|
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)
|
|
3348
3366
|
|
|
3349
3367
|
|
|
3350
3368
|
# ---------------------------------------------------------------------------
|
|
@@ -3353,9 +3371,15 @@ async def generate_title(
|
|
|
3353
3371
|
|
|
3354
3372
|
# GPT-6.1 Sol requires Responses for function tools and does not support
|
|
3355
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.
|
|
3356
3376
|
ORCHESTRATOR_MODEL = "gpt-6.1-sol"
|
|
3357
3377
|
|
|
3358
3378
|
|
|
3379
|
+
def _chat_model() -> str:
|
|
3380
|
+
return os.environ.get("LAMBDA_ERP_CHAT_MODEL") or ORCHESTRATOR_MODEL
|
|
3381
|
+
|
|
3382
|
+
|
|
3359
3383
|
# ---------------------------------------------------------------------------
|
|
3360
3384
|
# LLM API selector: Responses API (default) vs Chat Completions
|
|
3361
3385
|
#
|
|
@@ -3370,8 +3394,9 @@ ORCHESTRATOR_MODEL = "gpt-6.1-sol"
|
|
|
3370
3394
|
# ---------------------------------------------------------------------------
|
|
3371
3395
|
|
|
3372
3396
|
def _use_responses_api() -> bool:
|
|
3373
|
-
# A legacy deployment flag must not route
|
|
3374
|
-
|
|
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-"):
|
|
3375
3400
|
return True
|
|
3376
3401
|
# Default: the Responses API (needed for Office attachments + native
|
|
3377
3402
|
# reasoning). Set ERP_CHAT_API=chat to fall back to Chat Completions.
|
|
@@ -3532,12 +3557,12 @@ def _shim_usage_from_responses(usage):
|
|
|
3532
3557
|
|
|
3533
3558
|
def _orchestrator_turn(client, messages, tools, max_tokens, *, tool_choice="auto"):
|
|
3534
3559
|
"""One orchestrator LLM turn. Returns (message, usage) shaped like Chat
|
|
3535
|
-
Completions.
|
|
3560
|
+
Completions. GPT-6.1 models always use Responses; others honor ERP_CHAT_API.
|
|
3536
3561
|
Blocking — call via asyncio.to_thread."""
|
|
3537
3562
|
if _use_responses_api():
|
|
3538
3563
|
instructions, input_items = _responses_request_from_chat(messages)
|
|
3539
3564
|
resp = client.responses.create(
|
|
3540
|
-
model=
|
|
3565
|
+
model=_chat_model(),
|
|
3541
3566
|
instructions=instructions,
|
|
3542
3567
|
input=input_items,
|
|
3543
3568
|
tools=_responses_tools_from_chat(tools),
|
|
@@ -3547,7 +3572,7 @@ def _orchestrator_turn(client, messages, tools, max_tokens, *, tool_choice="auto
|
|
|
3547
3572
|
)
|
|
3548
3573
|
return _shim_message_from_responses(resp), _shim_usage_from_responses(getattr(resp, "usage", None))
|
|
3549
3574
|
resp = client.chat.completions.create(
|
|
3550
|
-
model=
|
|
3575
|
+
model=_chat_model(),
|
|
3551
3576
|
messages=messages,
|
|
3552
3577
|
tools=tools,
|
|
3553
3578
|
tool_choice=tool_choice,
|
|
@@ -3712,7 +3737,7 @@ async def run_thinking_loop(
|
|
|
3712
3737
|
# on ANY exit path — including asyncio.CancelledError raised after
|
|
3713
3738
|
# the SDK call has already returned but before settle() ran.
|
|
3714
3739
|
settled = False
|
|
3715
|
-
model_name =
|
|
3740
|
+
model_name = _chat_model()
|
|
3716
3741
|
try:
|
|
3717
3742
|
print(f"[chat_llm] provider=openai model={model_name} session_id={session_id or '-'} iter={iteration + 1}", flush=True)
|
|
3718
3743
|
await on_event({"type": "llm_provider", "provider": "openai", "model": model_name})
|
|
@@ -3899,9 +3924,9 @@ async def run_thinking_loop(
|
|
|
3899
3924
|
tool_choice="none",
|
|
3900
3925
|
)
|
|
3901
3926
|
demo_limiter.settle(
|
|
3902
|
-
None, actual_cost_usd=cost_of_openai_call(
|
|
3927
|
+
None, actual_cost_usd=cost_of_openai_call(_chat_model(), usage),
|
|
3903
3928
|
ip=client_ip or "unknown", role=user_role, provider="openai",
|
|
3904
|
-
model=
|
|
3929
|
+
model=_chat_model(),
|
|
3905
3930
|
prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0) if usage else 0,
|
|
3906
3931
|
completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0) if usage else 0,
|
|
3907
3932
|
session_id=session_id,
|
|
@@ -24,6 +24,13 @@ draft creation does not imply submission, stock movement or ledger posting. Chec
|
|
|
24
24
|
status and warnings. Correct validation errors using the field metadata and user information.
|
|
25
25
|
Use list/search filters and pagination; an empty or partial search is not proof of zero
|
|
26
26
|
stock. Use query_dataset/get_report for totals and stock, with the requested scope and dates.
|
|
27
|
+
Text for the customer or supplier goes in a document's printed `remarks` (Notes / Terms)
|
|
28
|
+
field. Internal team remarks ("customer disputes line 2", "@Anna please check") go in an
|
|
29
|
+
internal note instead (add_record_note / list_record_notes): never printed, visible to
|
|
30
|
+
signed-in colleagues only, and @mentions there notify colleagues. Never put internal
|
|
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.
|
|
27
34
|
Generate a fresh PDF after edits. Only a successful artifact descriptor proves generation;
|
|
28
35
|
download that artifact, check its SHA-256 and deliver it through the calling application's
|
|
29
36
|
attachment mechanism. Do not invent download URLs or reuse a stale document attachment.
|
|
@@ -59,6 +59,10 @@ async def lifespan(app: FastAPI):
|
|
|
59
59
|
with db.startup_lock():
|
|
60
60
|
from api.mentions import initialize_mentions
|
|
61
61
|
initialize_mentions()
|
|
62
|
+
from api.record_notes import initialize_record_notes
|
|
63
|
+
initialize_record_notes()
|
|
64
|
+
from api.menu_layout import initialize_menu_layout
|
|
65
|
+
initialize_menu_layout()
|
|
62
66
|
|
|
63
67
|
# Load customer extension plugins before anything creates documents.
|
|
64
68
|
load_plugins()
|
|
@@ -18,6 +18,7 @@ from lambda_erp.database import get_db
|
|
|
18
18
|
from lambda_erp.utils import now
|
|
19
19
|
|
|
20
20
|
SOURCES = {}
|
|
21
|
+
_FALLBACKS = [] # (resolve(doctype) -> source | None, list_types() -> [doctype]) for unregistered types
|
|
21
22
|
REAL_ROLES = {"viewer", "manager", "admin"}
|
|
22
23
|
_WRITING_MENTION_NOTE = ContextVar("writing_mention_note", default=False)
|
|
23
24
|
_MENTION_START = re.compile(r"(?<![\w@./+\-])@(?=\S)")
|
|
@@ -79,6 +80,31 @@ def register_mention_source(doctype, *, label, can_access, create_note, resolve_
|
|
|
79
80
|
create_note=create_note, resolve_note=resolve_note, resolve_notes=resolve_notes)
|
|
80
81
|
|
|
81
82
|
|
|
83
|
+
def register_mention_source_fallback(resolve, list_types):
|
|
84
|
+
"""Resolve sources for record types without an explicit registration
|
|
85
|
+
(api.record_notes uses this for every document and master type)."""
|
|
86
|
+
if all(entry[0] is not resolve for entry in _FALLBACKS):
|
|
87
|
+
_FALLBACKS.append((resolve, list_types))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def find_source(doctype):
|
|
91
|
+
source = SOURCES.get(doctype)
|
|
92
|
+
for resolve, _ in _FALLBACKS:
|
|
93
|
+
if source:
|
|
94
|
+
break
|
|
95
|
+
source = resolve(doctype)
|
|
96
|
+
return source
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def supported_sources():
|
|
100
|
+
sources = {key: value["label"] for key, value in SOURCES.items()}
|
|
101
|
+
for resolve, list_types in _FALLBACKS:
|
|
102
|
+
for doctype in list_types():
|
|
103
|
+
if doctype not in sources and (source := resolve(doctype)):
|
|
104
|
+
sources[doctype] = source["label"]
|
|
105
|
+
return [{"doctype": key, "label": label} for key, label in sources.items()]
|
|
106
|
+
|
|
107
|
+
|
|
82
108
|
class Input(BaseModel):
|
|
83
109
|
model_config = ConfigDict(extra="forbid", strict=True)
|
|
84
110
|
|
|
@@ -121,7 +147,7 @@ def _identity(actor):
|
|
|
121
147
|
|
|
122
148
|
|
|
123
149
|
def _source(doctype):
|
|
124
|
-
source =
|
|
150
|
+
source = find_source(doctype)
|
|
125
151
|
if not source:
|
|
126
152
|
raise ValueError("This record type does not support mention notes.")
|
|
127
153
|
return source
|
|
@@ -141,7 +167,7 @@ def search_mention_users(args, user):
|
|
|
141
167
|
token = full_name if full_name and len(aliases.get(full_name.lower(), {})) == 1 else row["email"]
|
|
142
168
|
row["mention_text"] = "@" + token
|
|
143
169
|
return {"users": rows, "acting_user": _identity(actor),
|
|
144
|
-
"sources":
|
|
170
|
+
"sources": supported_sources()}
|
|
145
171
|
|
|
146
172
|
|
|
147
173
|
def add_record_note(args, user):
|
|
@@ -221,7 +247,7 @@ def sync_note_mentions(doctype, record_id, note_id, body, user):
|
|
|
221
247
|
|
|
222
248
|
|
|
223
249
|
def _visible(row, actor, notes=None, authors=None):
|
|
224
|
-
source =
|
|
250
|
+
source = find_source(row["doctype"])
|
|
225
251
|
note = (notes.get((row["doctype"], row["note_id"])) if notes is not None else
|
|
226
252
|
source["resolve_note"](row["note_id"], actor) if source else None)
|
|
227
253
|
if not note:
|
|
@@ -242,7 +268,7 @@ def list_my_mentions(args, user):
|
|
|
242
268
|
# source and author lookups so polling a populated inbox avoids N+1 queries.
|
|
243
269
|
notes, authors = {}, {}
|
|
244
270
|
for doctype in {row["doctype"] for row in rows}:
|
|
245
|
-
source =
|
|
271
|
+
source = find_source(doctype)
|
|
246
272
|
if not source:
|
|
247
273
|
continue
|
|
248
274
|
ids = list({row["note_id"] for row in rows if row["doctype"] == doctype})
|
|
@@ -301,7 +327,7 @@ def initialize_mentions():
|
|
|
301
327
|
("search_mention_users", search_mention_users, Search, "viewer",
|
|
302
328
|
"Find enabled ERP colleagues for visible @mentions by name/email. Returns mention_text: insert that exact text in the note body to tag the person. Also returns canonical user IDs, acting_user and supported record types. Do not guess a person when ambiguous."),
|
|
303
329
|
("add_record_note", add_record_note, Note, "manager",
|
|
304
|
-
"Save an internal note and notify people visibly @mentioned in its body, including yourself.
|
|
330
|
+
"Save an internal team note on a record and notify people visibly @mentioned in its body, including yourself. doctype is the record type slug: any document or master type (e.g. sales-invoice, purchase-order, customer, supplier, item) or a plugin type listed in search_mention_users sources; record_id is the record's name. Internal notes are never printed and do not change the record; customer-facing text belongs in the document's printed `remarks` (Notes / Terms) field instead. A note needs no mention. To tag someone, first use search_mention_users, then include each recipient's exact mention_text in body, e.g. 'salü vom johnny @Jon Torus'. Recipients are determined only from body; there is no recipient-ID argument. Unknown @handles and ordinary emails remain text. Ambiguous names require @email. CRM lead notes written through the generic document tools use the same mention processing. Use a new request_id (UUID) per intended note; reuse it unchanged on retries. No email is sent. Authorship is the authenticated ERP account."),
|
|
305
331
|
("list_my_mentions", list_my_mentions, Inbox, "viewer",
|
|
306
332
|
"List the authenticated ERP user's mentions, with record/note links. open means not done; unread is separate. Use this for 'my mentions', never guess or pass another user. Returns acting_user, unread_count and pagination."),
|
|
307
333
|
("set_mention_state", set_mention_state, State, "viewer",
|
|
@@ -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`.
|
|
4
|
-
|
|
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
|
-
#
|
|
23
|
-
# the higher rates apply to the entire request
|
|
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-
|
|
40
|
+
"gpt-6.1-terra": {
|
|
53
41
|
"tiered": True,
|
|
54
42
|
"tiers": [
|
|
55
|
-
{"max_input_tokens": 272_000, "input":
|
|
56
|
-
{"max_input_tokens": None, "input":
|
|
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
|
-
|
|
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":
|
|
65
|
-
{"max_input_tokens": None, "input":
|
|
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 —
|
|
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-
|
|
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")
|