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.
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/PKG-INFO +6 -4
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/README.md +5 -3
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/chat.py +42 -20
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/erp_context.py +2 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/main.py +2 -0
- lambda_erp-1.3.3/api/menu_layout.py +206 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/providers.py +18 -40
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/mcp.py +1 -1
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/pyproject.toml +1 -1
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/.gitignore +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/LICENSE +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/__init__.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/attachments.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/auth.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/bootstrap.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/chat_links.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/demo_limits.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/deps.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/errors.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/list_values.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/mentions.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/oauth.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/pdf.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/pdf_contract.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/pdf_exports.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/record_notes.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/remarks_md.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/__init__.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/accounting.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/actions.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/admin.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/analytics.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/availability.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/documents.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/masters.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/proposals.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/reports.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/routers/setup.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/services.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/templates/document.html +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/templates/proposal.html +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/templates/record.html +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/time_filters.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/tool_permissions.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/api/write_context.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/docs/agents/README.md +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/frontend/LICENSE +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/frontend/README.md +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/database.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/model.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.3.1 → lambda_erp-1.3.3}/lambda_erp/workflow.py +0 -0
- {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.
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
3332
|
+
client.responses.create,
|
|
3322
3333
|
model=title_model,
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
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, "
|
|
3338
|
-
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,
|
|
3339
3349
|
session_id=session_id,
|
|
3340
3350
|
)
|
|
3341
3351
|
settled = True
|
|
3342
3352
|
|
|
3343
|
-
title = response.
|
|
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
|
-
|
|
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
|
|
3377
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
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 =
|
|
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(
|
|
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=
|
|
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`.
|
|
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")
|
|
@@ -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',
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|