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.
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/PKG-INFO +2 -2
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/README.md +1 -1
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/attachments.py +5 -7
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/chat.py +18 -27
- lambda_erp-1.4.1/api/llm_provider.py +97 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/pyproject.toml +1 -1
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/.gitignore +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/LICENSE +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/__init__.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/auth.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/bootstrap.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/chat_links.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/demo_limits.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/deps.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/erp_context.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/errors.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/list_values.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/main.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/mentions.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/menu_layout.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/oauth.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_contract.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_exports.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_fonts.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_pages.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/pdf_templates.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/providers.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/record_notes.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/remarks_md.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/__init__.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/accounting.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/actions.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/admin.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/analytics.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/availability.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/documents.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/masters.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/mcp.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/pdf_templates.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/proposals.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/reports.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/routers/setup.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/services.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/delivery_note.html +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/document.html +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/proposal.html +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/record.html +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/templates/reservation.html +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/time_filters.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/tool_permissions.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/api/write_context.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/docs/agents/README.md +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/frontend/LICENSE +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/frontend/README.md +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/database.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/model.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.4.0 → lambda_erp-1.4.1}/lambda_erp/workflow.py +0 -0
- {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.
|
|
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
|
-
"""
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
3250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3712
|
-
|
|
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
|
-
|
|
3718
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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))
|
|
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
|
|
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
|