lambda-erp 1.4.0__tar.gz → 1.4.1__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 (125) hide show
  1. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/PKG-INFO +2 -2
  2. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/README.md +1 -1
  3. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/attachments.py +5 -7
  4. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/chat.py +18 -27
  5. lambda_erp-1.4.1/api/llm_provider.py +97 -0
  6. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/pyproject.toml +1 -1
  7. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/.gitignore +0 -0
  8. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/LICENSE +0 -0
  9. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/__init__.py +0 -0
  10. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/auth.py +0 -0
  11. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/bootstrap.py +0 -0
  12. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/chat_links.py +0 -0
  13. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/demo_limits.py +0 -0
  14. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/deps.py +0 -0
  15. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/erp_context.py +0 -0
  16. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/errors.py +0 -0
  17. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/list_values.py +0 -0
  18. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/main.py +0 -0
  19. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/mcp_oauth.py +0 -0
  20. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/mentions.py +0 -0
  21. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/menu_layout.py +0 -0
  22. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/oauth.py +0 -0
  23. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf.py +0 -0
  24. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_contract.py +0 -0
  25. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_exports.py +0 -0
  26. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_fonts.py +0 -0
  27. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_pages.py +0 -0
  28. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_profiles.py +0 -0
  29. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_templates.py +0 -0
  30. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/providers.py +0 -0
  31. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/record_notes.py +0 -0
  32. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/remarks_md.py +0 -0
  33. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/__init__.py +0 -0
  34. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/accounting.py +0 -0
  35. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/actions.py +0 -0
  36. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/admin.py +0 -0
  37. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/analytics.py +0 -0
  38. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/availability.py +0 -0
  39. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/bank_reconciliation.py +0 -0
  40. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/bank_statements.py +0 -0
  41. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/chat_api.py +0 -0
  42. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/documents.py +0 -0
  43. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/masters.py +0 -0
  44. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/mcp.py +0 -0
  45. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/pdf_templates.py +0 -0
  46. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/proposals.py +0 -0
  47. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/reports.py +0 -0
  48. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/setup.py +0 -0
  49. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/services.py +0 -0
  50. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/_proposal_totals.html +0 -0
  51. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/_record_base.html +0 -0
  52. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/delivery_note.html +0 -0
  53. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/document.html +0 -0
  54. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/proposal.html +0 -0
  55. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/record.html +0 -0
  56. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/reservation.html +0 -0
  57. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/time_filters.py +0 -0
  58. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/tool_permissions.py +0 -0
  59. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/write_context.py +0 -0
  60. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/docs/agents/README.md +0 -0
  61. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/frontend/LICENSE +0 -0
  62. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/frontend/README.md +0 -0
  63. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/frontend/src/api/client.ts +0 -0
  64. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/__init__.py +0 -0
  65. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/__init__.py +0 -0
  66. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_account.py +0 -0
  67. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  68. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_statement_import.py +0 -0
  69. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_transaction.py +0 -0
  70. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/budget.py +0 -0
  71. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/camt.py +0 -0
  72. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  73. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/general_ledger.py +0 -0
  74. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/journal_entry.py +0 -0
  75. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/payment_entry.py +0 -0
  76. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/pos_invoice.py +0 -0
  77. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/purchase_invoice.py +0 -0
  78. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/revaluation.py +0 -0
  79. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/sales_invoice.py +0 -0
  80. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/settlement.py +0 -0
  81. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/__init__.py +0 -0
  82. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/engine.py +0 -0
  83. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/pack.py +0 -0
  84. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  85. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  86. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  87. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  88. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  89. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  90. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/profiles.py +0 -0
  91. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/spine.py +0 -0
  92. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/subscription.py +0 -0
  93. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/assets/__init__.py +0 -0
  94. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/assets/asset.py +0 -0
  95. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/assets/lifecycle.py +0 -0
  96. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/assets/reservation.py +0 -0
  97. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/buying/__init__.py +0 -0
  98. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/buying/purchase_order.py +0 -0
  99. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/__init__.py +0 -0
  100. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/currency.py +0 -0
  101. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/defaults.py +0 -0
  102. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/item_price.py +0 -0
  103. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/item_prices.py +0 -0
  104. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/pricing_rule.py +0 -0
  105. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  106. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/database.py +0 -0
  107. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/exceptions.py +0 -0
  108. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/hooks.py +0 -0
  109. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/master_integrity.py +0 -0
  110. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/model.py +0 -0
  111. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/selling/__init__.py +0 -0
  112. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/selling/proposal.py +0 -0
  113. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/selling/quotation.py +0 -0
  114. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/selling/sales_order.py +0 -0
  115. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/simulation.py +0 -0
  116. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/stock/__init__.py +0 -0
  117. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/stock/delivery_note.py +0 -0
  118. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/stock/purchase_receipt.py +0 -0
  119. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/stock/stock_entry.py +0 -0
  120. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/stock/stock_ledger.py +0 -0
  121. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/timestamps.py +0 -0
  122. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/utils.py +0 -0
  123. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/validation.py +0 -0
  124. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/workflow.py +0 -0
  125. {lambda_erp-1.4.0 → lambda_erp-1.4.1}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.4.0
3
+ Version: 1.4.1
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -164,6 +164,7 @@ The result is booked in one step — chart of accounts, sensible default account
164
164
  - Custom analytics drafts via chat (persisted, shareable, editable)
165
165
  - Server-side aggregation tool for in-chat factual answers across large datasets
166
166
  - PDF / image attachment → add invoices, create quotations, etc. all directly by adding them in the chat
167
+ - **PDF output with your own layouts** — every offer, order, invoice, purchase order, delivery note, proposal and reservation prints as a PDF. Layouts are HTML/CSS templates with your own images and fonts, edited in the settings or simply by asking the chat ("make the offer layout look like our letterhead"), previewed against real documents, then published as versions; submitted documents keep the exact layout they were issued with. See [`docs/pdf-output.md`](docs/pdf-output.md).
167
168
  - **Programmatic API** — drive the ERP from external systems with per-user, role-capped Bearer keys: a [chat API](docs/chat-api.md) (converse with the agent) and a [REST API](docs/rest-api.md) (documents, masters, reports — the same endpoints the web app uses, so a connector or sync job can create and submit documents directly). Both off by default; an admin enables each independently and issues keys.
168
169
  - **MCP server** — the ERP speaks the [Model Context Protocol](https://modelcontextprotocol.io) at `POST /api/mcp`, so **any MCP-capable agent (Claude, Codex, an IDE assistant) can drive it directly** — list/create/update/submit documents, manage master data, run reports — as *structured, validated tool calls*, not brittle screen-scraping or hand-written HTTP glue. The tool list is built from the live registries, so a plugin's own doctypes (e.g. a CRM's leads/contacts) are exposed automatically. It reuses the *same* Bearer keys and roles as the REST API — a viewer key is read-only, a manager key can write — so access is scoped, auditable, and off by default. In practice: point your assistant at your instance and ask "which invoices are overdue?" or "create a quotation for Acme with 3× SVC-SPARK" and it operates on real data, from wherever you already work. The API-keys settings page hands you ready-to-paste Claude and Codex config the moment you create a key.
169
170
  - Auth with admin/manager/viewer roles plus a public demo mode
@@ -178,7 +179,6 @@ The result is booked in one step — chart of accounts, sensible default account
178
179
  - Manufacturing (BOM, work orders)
179
180
  - HR / Payroll beyond the journal-entry workaround
180
181
  - Regional compliance packs (GST, VAT returns, etc.) - see below
181
- - PDF report creation directly inside the chat
182
182
 
183
183
  ---
184
184
 
@@ -134,6 +134,7 @@ The result is booked in one step — chart of accounts, sensible default account
134
134
  - Custom analytics drafts via chat (persisted, shareable, editable)
135
135
  - Server-side aggregation tool for in-chat factual answers across large datasets
136
136
  - PDF / image attachment → add invoices, create quotations, etc. all directly by adding them in the chat
137
+ - **PDF output with your own layouts** — every offer, order, invoice, purchase order, delivery note, proposal and reservation prints as a PDF. Layouts are HTML/CSS templates with your own images and fonts, edited in the settings or simply by asking the chat ("make the offer layout look like our letterhead"), previewed against real documents, then published as versions; submitted documents keep the exact layout they were issued with. See [`docs/pdf-output.md`](docs/pdf-output.md).
137
138
  - **Programmatic API** — drive the ERP from external systems with per-user, role-capped Bearer keys: a [chat API](docs/chat-api.md) (converse with the agent) and a [REST API](docs/rest-api.md) (documents, masters, reports — the same endpoints the web app uses, so a connector or sync job can create and submit documents directly). Both off by default; an admin enables each independently and issues keys.
138
139
  - **MCP server** — the ERP speaks the [Model Context Protocol](https://modelcontextprotocol.io) at `POST /api/mcp`, so **any MCP-capable agent (Claude, Codex, an IDE assistant) can drive it directly** — list/create/update/submit documents, manage master data, run reports — as *structured, validated tool calls*, not brittle screen-scraping or hand-written HTTP glue. The tool list is built from the live registries, so a plugin's own doctypes (e.g. a CRM's leads/contacts) are exposed automatically. It reuses the *same* Bearer keys and roles as the REST API — a viewer key is read-only, a manager key can write — so access is scoped, auditable, and off by default. In practice: point your assistant at your instance and ask "which invoices are overdue?" or "create a quotation for Acme with 3× SVC-SPARK" and it operates on real data, from wherever you already work. The API-keys settings page hands you ready-to-paste Claude and Codex config the moment you create a key.
139
140
  - Auth with admin/manager/viewer roles plus a public demo mode
@@ -148,7 +149,6 @@ The result is booked in one step — chart of accounts, sensible default account
148
149
  - Manufacturing (BOM, work orders)
149
150
  - HR / Payroll beyond the journal-entry workaround
150
151
  - Regional compliance packs (GST, VAT returns, etc.) - see below
151
- - PDF report creation directly inside the chat
152
152
 
153
153
  ---
154
154
 
@@ -9,6 +9,7 @@ import time
9
9
  import uuid
10
10
  from typing import Optional
11
11
 
12
+ import httpx
12
13
  import openpyxl
13
14
  from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
14
15
  from fastapi.responses import Response
@@ -17,6 +18,7 @@ from openai import OpenAI
17
18
  from lambda_erp.database import get_db
18
19
  from lambda_erp.utils import now
19
20
  from api.auth import require_role, get_current_user
21
+ from api.llm_provider import client_kwargs
20
22
  from api.demo_limits import demo_max_attachment_bytes, is_demo_role
21
23
 
22
24
  logger = logging.getLogger(__name__)
@@ -105,7 +107,6 @@ _OPENAI_FILE_TTL_SECONDS = 30 * 24 * 3600 # 30 days
105
107
  _OPENAI_FILE_REUSE_MARGIN = 300 # re-upload if within 5 min of expiry
106
108
  SPREADSHEET_ROW_WARN_LIMIT = 1000 # OpenAI augments ~1000 rows/sheet
107
109
 
108
- _openai_singleton: Optional[OpenAI] = None
109
110
  _expires_after_supported = True # flips off if the API rejects it once
110
111
 
111
112
  # The single Office spreadsheet mime we can cheaply row-count (openpyxl).
@@ -113,12 +114,9 @@ _XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
113
114
 
114
115
 
115
116
  def _openai_client() -> OpenAI:
116
- """Lazily build a module-level OpenAI client for Files uploads (a separate
117
- call from the chat completion; same OPENAI_API_KEY)."""
118
- global _openai_singleton
119
- if _openai_singleton is None:
120
- _openai_singleton = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
121
- return _openai_singleton
117
+ """An OpenAI client for Files uploads (a separate call from the chat
118
+ turn; same provider and key, see api/llm_provider.py)."""
119
+ return OpenAI(**client_kwargs(httpx.Timeout(60.0, connect=10.0)))
122
120
 
123
121
 
124
122
  def _create_openai_file(att: dict):
@@ -39,6 +39,7 @@ from api.demo_limits import (
39
39
  is_demo_role,
40
40
  limiter as demo_limiter,
41
41
  )
42
+ from api.llm_provider import client_kwargs, llm_configured, not_configured_message
42
43
  from api.providers import cost_of_openai_call, cost_of_transcription
43
44
  from api.routers.masters import create_master_record, update_master_record, delete_master_record
44
45
  from api.routers.analytics import DATASET_FILTER_DESCRIPTION
@@ -3034,7 +3035,7 @@ Users can attach PDFs and images (receipts, bills, contracts, screenshots) to th
3034
3035
  # GPT-6.1 Sol acts as the planner/orchestrator. When it decides a report needs
3035
3036
  # a custom analytics view it calls the create/update custom analytics tools.
3036
3037
  # Those handlers make a separate, narrowly prompted GPT-6.1 Sol call for the bounded
3037
- # declarative report spec. Both roles use the existing OPENAI_API_KEY.
3038
+ # declarative report spec. Both roles use the same provider and key (api/llm_provider.py).
3038
3039
  # ---------------------------------------------------------------------------
3039
3040
 
3040
3041
 
@@ -3246,9 +3247,8 @@ def _generate_report_spec_via_openai(
3246
3247
  user_role: str | None = None,
3247
3248
  session_id: str | None = None,
3248
3249
  ) -> dict:
3249
- api_key = os.environ.get("OPENAI_API_KEY", "")
3250
- if not api_key or api_key == "sk-your-key-here":
3251
- raise RuntimeError("OPENAI_API_KEY is not configured")
3250
+ if not llm_configured():
3251
+ raise RuntimeError(not_configured_message())
3252
3252
 
3253
3253
  model = _report_specialist_model()
3254
3254
 
@@ -3278,10 +3278,7 @@ def _generate_report_spec_via_openai(
3278
3278
  )
3279
3279
  user_msg = "\n\n".join(user_parts)
3280
3280
 
3281
- client = OpenAI(
3282
- api_key=api_key,
3283
- timeout=httpx.Timeout(120.0, connect=10.0),
3284
- )
3281
+ client = OpenAI(**client_kwargs(httpx.Timeout(120.0, connect=10.0)))
3285
3282
  validation_feedback: str | None = None
3286
3283
  max_attempts = 2
3287
3284
 
@@ -3402,16 +3399,12 @@ async def generate_title(
3402
3399
  title shows up while the reply is still being written. Without a text
3403
3400
  message (attachments only) it runs after the first reply and uses that too.
3404
3401
  """
3405
- api_key = os.environ.get("OPENAI_API_KEY", "")
3406
- if not api_key or api_key == "sk-your-key-here":
3402
+ if not llm_configured():
3407
3403
  return
3408
3404
 
3409
3405
  title_model = _title_model()
3410
3406
  try:
3411
- client = OpenAI(
3412
- api_key=api_key,
3413
- timeout=httpx.Timeout(30.0, connect=5.0),
3414
- )
3407
+ client = OpenAI(**client_kwargs(httpx.Timeout(30.0, connect=5.0)))
3415
3408
  reservation_id = None
3416
3409
  if is_demo_role(user_role):
3417
3410
  _blocked, reservation_id = demo_limiter.reserve(
@@ -3708,15 +3701,14 @@ async def run_thinking_loop(
3708
3701
  declarative-spec step to a separate narrowly prompted GPT-6.1 Sol call. We emit an
3709
3702
  `llm_provider` event around that delegation so the UI can surface it.
3710
3703
  """
3711
- openai_api_key = os.environ.get("OPENAI_API_KEY", "")
3712
- if not openai_api_key or openai_api_key == "sk-your-key-here":
3713
- await on_event({"type": "error", "content": "Error: OPENAI_API_KEY is not configured. Please set it in the .env file."})
3704
+ if not llm_configured():
3705
+ await on_event({"type": "error", "content": f"Error: {not_configured_message()}. Set it in the environment (.env)."})
3714
3706
  return
3715
3707
 
3716
- openai_client = OpenAI(
3717
- api_key=openai_api_key,
3718
- timeout=httpx.Timeout(120.0, connect=10.0),
3719
- )
3708
+ openai_client = OpenAI(**client_kwargs(
3709
+ httpx.Timeout(120.0, connect=10.0),
3710
+ erp_user=user_info.get("name") if user_info else None,
3711
+ ))
3720
3712
 
3721
3713
  # Closures below read through this reference so a live role/key refresh
3722
3714
  # immediately affects resource-aware handlers as well as the shared policy.
@@ -4079,14 +4071,14 @@ VOICE_TRANSCRIBE_PROMPT = (
4079
4071
  _MIN_TRANSCRIBE_BYTES = 1500 # ~1s of webm/opus at the ~1.5 KB/s we assume below
4080
4072
 
4081
4073
 
4082
- def transcribe_audio(audio_bytes: bytes, audio_format: str, api_key: str) -> str:
4074
+ def transcribe_audio(audio_bytes: bytes, audio_format: str, erp_user: str | None = None) -> str:
4083
4075
  """Transcribe audio to text via OpenAI gpt-4o-transcribe.
4084
4076
 
4085
4077
  Returns the transcript, or "" when the model detects silence/near-silence
4086
4078
  (empty result or a prompt-echo hallucination on a near-silent clip).
4087
4079
  Raises on an actual API error so the caller can surface it.
4088
4080
  """
4089
- client = OpenAI(api_key=api_key, timeout=httpx.Timeout(60.0, connect=10.0))
4081
+ client = OpenAI(**client_kwargs(httpx.Timeout(60.0, connect=10.0), erp_user=erp_user))
4090
4082
 
4091
4083
  audio_file = io.BytesIO(audio_bytes)
4092
4084
  audio_file.name = f"voice.{audio_format or 'webm'}"
@@ -4583,11 +4575,10 @@ async def chat_websocket(
4583
4575
  # Voice → text: transcribe a recorded clip and return the text
4584
4576
  # for the composer. No LLM turn here — the user reviews/edits
4585
4577
  # the text and sends it like any typed message.
4586
- openai_api_key = os.environ.get("OPENAI_API_KEY", "")
4587
- if not openai_api_key or openai_api_key == "sk-your-key-here":
4578
+ if not llm_configured():
4588
4579
  await send_event(
4589
4580
  "transcription_result", request_id=request_id,
4590
- success=False, error="Speech-to-text is not available (OPENAI_API_KEY not configured).",
4581
+ success=False, error=f"Speech-to-text is not available ({not_configured_message()}).",
4591
4582
  )
4592
4583
  continue
4593
4584
 
@@ -4620,7 +4611,7 @@ async def chat_websocket(
4620
4611
 
4621
4612
  try:
4622
4613
  text = await asyncio.to_thread(
4623
- transcribe_audio, audio_bytes, audio_format, openai_api_key,
4614
+ transcribe_audio, audio_bytes, audio_format, ws_user_id,
4624
4615
  )
4625
4616
  except Exception as e:
4626
4617
  logger.warning("Transcription failed: %s", e)
@@ -0,0 +1,97 @@
1
+ """Where the ERP's model calls go.
2
+
3
+ LAMBDA_ERP_LLM_PROVIDER=openai (default) OpenAI directly with OPENAI_API_KEY.
4
+ OPENAI_BASE_URL is honoured by the SDK as
5
+ before, for any OpenAI-compatible endpoint.
6
+ LAMBDA_ERP_LLM_PROVIDER=lambda Lambda's model gateway (lambda.dev): the
7
+ same OpenAI API, billed to the Lambda
8
+ organization the key belongs to.
9
+ LAMBDA_LLM_API_KEY a Lambda model key
10
+ (scope llm:erp)
11
+ LAMBDA_LLM_BASE_URL default
12
+ https://platform.lambda.dev/v1/llm/openai
13
+
14
+ The gateway relays what the ERP uses: Responses (chat, report specialist,
15
+ titles), file uploads for attachments and voice transcription. Chat
16
+ Completions (ERP_CHAT_API=chat) is not available through it. With the lambda
17
+ provider each call names the ERP user (``X-Lambda-ERP-User``, the login name),
18
+ so Lambda's usage view can split the organization's cost by person; OpenAI
19
+ never sees that header.
20
+
21
+ Every OpenAI client in the ERP is built from ``client_kwargs()``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from dataclasses import dataclass
28
+ from typing import Optional
29
+
30
+ import httpx
31
+ from openai import OpenAI
32
+
33
+ DEFAULT_LAMBDA_BASE_URL = "https://platform.lambda.dev/v1/llm/openai"
34
+ ERP_USER_HEADER = "X-Lambda-ERP-User"
35
+ _PLACEHOLDERS = {"", "sk-your-key-here"}
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class LLMSettings:
40
+ provider: str # "openai" or "lambda"
41
+ api_key: str
42
+ base_url: Optional[str] # None: the SDK's default (or OPENAI_BASE_URL)
43
+ key_variable: str # the env variable the key comes from, for messages
44
+
45
+ @property
46
+ def configured(self) -> bool:
47
+ return self.api_key.strip() not in _PLACEHOLDERS
48
+
49
+
50
+ def llm_settings() -> LLMSettings:
51
+ """Read on every call, so a changed environment applies without a restart."""
52
+ provider = (os.environ.get("LAMBDA_ERP_LLM_PROVIDER") or "openai").strip().lower()
53
+ if provider == "lambda":
54
+ return LLMSettings(
55
+ provider="lambda",
56
+ api_key=os.environ.get("LAMBDA_LLM_API_KEY", ""),
57
+ base_url=(os.environ.get("LAMBDA_LLM_BASE_URL") or DEFAULT_LAMBDA_BASE_URL).rstrip("/"),
58
+ key_variable="LAMBDA_LLM_API_KEY",
59
+ )
60
+ if provider != "openai":
61
+ raise RuntimeError(f"LAMBDA_ERP_LLM_PROVIDER={provider!r}: use 'openai' or 'lambda'")
62
+ return LLMSettings(provider="openai", api_key=os.environ.get("OPENAI_API_KEY", ""),
63
+ base_url=None, key_variable="OPENAI_API_KEY")
64
+
65
+
66
+ def llm_configured() -> bool:
67
+ try:
68
+ return llm_settings().configured
69
+ except RuntimeError:
70
+ return False
71
+
72
+
73
+ def not_configured_message() -> str:
74
+ try:
75
+ settings = llm_settings()
76
+ except RuntimeError as exc:
77
+ return str(exc)
78
+ return f"{settings.key_variable} is not configured"
79
+
80
+
81
+ def client_kwargs(timeout: httpx.Timeout, erp_user: Optional[str] = None) -> dict:
82
+ """Keyword arguments for ``OpenAI(...)`` with the configured provider."""
83
+ settings = llm_settings()
84
+ headers = {}
85
+ if settings.provider == "lambda" and erp_user:
86
+ headers[ERP_USER_HEADER] = str(erp_user)[:100]
87
+ kwargs = {"api_key": settings.api_key, "timeout": timeout}
88
+ if settings.base_url:
89
+ kwargs["base_url"] = settings.base_url
90
+ if headers:
91
+ kwargs["default_headers"] = headers
92
+ return kwargs
93
+
94
+
95
+ def openai_client(timeout: httpx.Timeout, erp_user: Optional[str] = None) -> OpenAI:
96
+ """An OpenAI client for the configured provider."""
97
+ return OpenAI(**client_kwargs(timeout, erp_user))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "1.4.0"
3
+ version = "1.4.1"
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
File without changes
File without changes
File without changes
File without changes