lambda-erp 0.6.16__tar.gz → 0.6.18__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 (84) hide show
  1. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/PKG-INFO +1 -1
  2. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/attachments.py +9 -0
  3. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/chat.py +205 -16
  4. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/pyproject.toml +1 -1
  5. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/.gitignore +0 -0
  6. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/LICENSE +0 -0
  7. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/README.md +0 -0
  8. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/__init__.py +0 -0
  9. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/auth.py +0 -0
  10. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/bootstrap.py +0 -0
  11. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/demo_limits.py +0 -0
  12. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/deps.py +0 -0
  13. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/errors.py +0 -0
  14. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/main.py +0 -0
  15. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/oauth.py +0 -0
  16. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/pdf.py +0 -0
  17. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/providers.py +0 -0
  18. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/remarks_md.py +0 -0
  19. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/__init__.py +0 -0
  20. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/accounting.py +0 -0
  21. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/admin.py +0 -0
  22. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/analytics.py +0 -0
  23. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/bank_reconciliation.py +0 -0
  24. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/chat_api.py +0 -0
  25. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/documents.py +0 -0
  26. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/masters.py +0 -0
  27. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/mcp.py +0 -0
  28. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/proposals.py +0 -0
  29. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/reports.py +0 -0
  30. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/routers/setup.py +0 -0
  31. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/services.py +0 -0
  32. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/templates/document.html +0 -0
  33. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/api/templates/proposal.html +0 -0
  34. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/docs/agents/README.md +0 -0
  35. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/frontend/LICENSE +0 -0
  36. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/frontend/README.md +0 -0
  37. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/frontend/src/api/client.ts +0 -0
  38. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/__init__.py +0 -0
  39. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/__init__.py +0 -0
  40. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/bank_transaction.py +0 -0
  41. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/budget.py +0 -0
  42. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  43. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/general_ledger.py +0 -0
  44. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/journal_entry.py +0 -0
  45. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/payment_entry.py +0 -0
  46. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/pos_invoice.py +0 -0
  47. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/purchase_invoice.py +0 -0
  48. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/revaluation.py +0 -0
  49. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/sales_invoice.py +0 -0
  50. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/setup/__init__.py +0 -0
  51. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/setup/engine.py +0 -0
  52. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/setup/pack.py +0 -0
  53. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  54. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  55. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  56. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  57. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  58. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  59. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/setup/profiles.py +0 -0
  60. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/setup/spine.py +0 -0
  61. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/accounting/subscription.py +0 -0
  62. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/buying/__init__.py +0 -0
  63. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/buying/purchase_order.py +0 -0
  64. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/controllers/__init__.py +0 -0
  65. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/controllers/currency.py +0 -0
  66. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/controllers/defaults.py +0 -0
  67. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/controllers/pricing_rule.py +0 -0
  68. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  69. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/database.py +0 -0
  70. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/exceptions.py +0 -0
  71. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/hooks.py +0 -0
  72. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/model.py +0 -0
  73. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/selling/__init__.py +0 -0
  74. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/selling/proposal.py +0 -0
  75. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/selling/quotation.py +0 -0
  76. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/selling/sales_order.py +0 -0
  77. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/simulation.py +0 -0
  78. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/stock/__init__.py +0 -0
  79. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/stock/delivery_note.py +0 -0
  80. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/stock/purchase_receipt.py +0 -0
  81. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/stock/stock_entry.py +0 -0
  82. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/stock/stock_ledger.py +0 -0
  83. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/lambda_erp/utils.py +0 -0
  84. {lambda_erp-0.6.16 → lambda_erp-0.6.18}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.6.16
3
+ Version: 0.6.18
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -402,6 +402,15 @@ def build_multimodal_content(attachment: dict) -> dict:
402
402
  },
403
403
  }
404
404
  if mime in OFFICE_MIME_TYPES:
405
+ # Office bytes are read via the Files API (file_id) — but ONLY the
406
+ # Responses API accepts non-PDF file inputs. The Responses path is the
407
+ # default; only degrade gracefully (no hard 400) if someone has pinned
408
+ # the legacy Chat Completions backend via ERP_CHAT_API=chat.
409
+ if os.environ.get("ERP_CHAT_API", "responses").strip().lower() == "chat":
410
+ return {"type": "text",
411
+ "text": (f"[Attachment “{filename}” is an Office document; the current chat "
412
+ "backend can only read PDFs and images. Convert it to PDF, or ask an "
413
+ "admin to enable the Responses backend.]")}
405
414
  file_id = ensure_openai_file(attachment)
406
415
  if file_id:
407
416
  return {"type": "file", "file": {"file_id": file_id}}
@@ -2698,6 +2698,206 @@ async def generate_title(
2698
2698
  ORCHESTRATOR_MODEL = "gpt-5.6-terra"
2699
2699
 
2700
2700
 
2701
+ # ---------------------------------------------------------------------------
2702
+ # LLM API selector: Chat Completions (default) vs Responses API
2703
+ #
2704
+ # The Responses API (/v1/responses) is what accepts non-PDF file inputs
2705
+ # (Office documents via input_file), and it does reasoning natively (no
2706
+ # reasoning_effort="none" hack). Migrating the whole loop is risky, so it's
2707
+ # behind a flag and OFF by default. To keep the blast radius tiny, ALL
2708
+ # conversation state stays Chat-Completions-shaped; we translate to/from the
2709
+ # Responses shape only at the single orchestrator call boundary
2710
+ # (_orchestrator_turn), and shim the Responses output back into the
2711
+ # Chat-shaped objects the loop and providers.py already expect.
2712
+ # ---------------------------------------------------------------------------
2713
+
2714
+ def _use_responses_api() -> bool:
2715
+ # Default: the Responses API (needed for Office attachments + native
2716
+ # reasoning). Set ERP_CHAT_API=chat to fall back to Chat Completions.
2717
+ return os.environ.get("ERP_CHAT_API", "responses").strip().lower() != "chat"
2718
+
2719
+
2720
+ # Reasoning effort for the Responses path (native; the Chat path uses "none").
2721
+ _RESPONSES_REASONING_EFFORT = "low"
2722
+
2723
+
2724
+ class _ShimFn:
2725
+ def __init__(self, name, arguments):
2726
+ self.name = name
2727
+ self.arguments = arguments
2728
+
2729
+
2730
+ class _ShimToolCall:
2731
+ def __init__(self, call_id, name, arguments):
2732
+ self.id = call_id
2733
+ self.type = "function"
2734
+ self.function = _ShimFn(name, arguments)
2735
+
2736
+
2737
+ class _ShimMessage:
2738
+ """Quacks like a Chat Completions `choice.message`."""
2739
+ def __init__(self, content, tool_calls):
2740
+ self.content = content
2741
+ self.tool_calls = tool_calls or None
2742
+
2743
+
2744
+ class _ShimUsageDetails:
2745
+ def __init__(self, cached_tokens):
2746
+ self.cached_tokens = cached_tokens
2747
+
2748
+
2749
+ class _ShimUsage:
2750
+ """Quacks like a Chat Completions `response.usage` (so cost_of_openai_call
2751
+ and demo_limiter.settle read it unchanged)."""
2752
+ def __init__(self, prompt_tokens, completion_tokens, cached_tokens):
2753
+ self.prompt_tokens = prompt_tokens
2754
+ self.completion_tokens = completion_tokens
2755
+ self.prompt_tokens_details = _ShimUsageDetails(cached_tokens)
2756
+
2757
+
2758
+ def _responses_tools_from_chat(tools: list) -> list:
2759
+ """Chat tool schema {"type":"function","function":{name,...}} -> the flatter
2760
+ Responses shape {"type":"function","name",...}."""
2761
+ out = []
2762
+ for t in tools or []:
2763
+ fn = t.get("function") if isinstance(t, dict) else None
2764
+ if t.get("type") == "function" and fn:
2765
+ out.append({
2766
+ "type": "function",
2767
+ "name": fn["name"],
2768
+ "description": fn.get("description", ""),
2769
+ "parameters": fn.get("parameters") or {"type": "object", "properties": {}},
2770
+ })
2771
+ else:
2772
+ out.append(t)
2773
+ return out
2774
+
2775
+
2776
+ def _responses_msg_content(content):
2777
+ """A Chat message `content` -> Responses message content: a plain string
2778
+ passes through unchanged; a multimodal parts list becomes input_* parts
2779
+ (input_text/input_image/input_file). Only user messages carry lists."""
2780
+ if content is None:
2781
+ return ""
2782
+ if isinstance(content, str):
2783
+ return content
2784
+ parts = []
2785
+ for p in content:
2786
+ ptype = p.get("type")
2787
+ if ptype == "text":
2788
+ parts.append({"type": "input_text", "text": p.get("text", "")})
2789
+ elif ptype == "image_url":
2790
+ url = (p.get("image_url") or {}).get("url")
2791
+ parts.append({"type": "input_image", "image_url": url})
2792
+ elif ptype == "file":
2793
+ f = p.get("file") or {}
2794
+ item = {"type": "input_file"}
2795
+ if f.get("file_id"):
2796
+ item["file_id"] = f["file_id"]
2797
+ else:
2798
+ if f.get("filename"):
2799
+ item["filename"] = f["filename"]
2800
+ if f.get("file_data"):
2801
+ item["file_data"] = f["file_data"]
2802
+ parts.append(item)
2803
+ else:
2804
+ parts.append({"type": "input_text", "text": json.dumps(p, default=str)})
2805
+ return parts
2806
+
2807
+
2808
+ def _responses_request_from_chat(messages: list) -> tuple:
2809
+ """Translate Chat `messages` into (instructions, input_items) for Responses.
2810
+ The first system message becomes `instructions`; assistant tool_calls become
2811
+ `function_call` items and tool results become `function_call_output` items."""
2812
+ instructions = None
2813
+ input_items: list = []
2814
+ for m in messages:
2815
+ role = m.get("role")
2816
+ content = m.get("content")
2817
+ if role == "system":
2818
+ flat = content if isinstance(content, str) else " ".join(
2819
+ p.get("text", "") for p in (content or []) if isinstance(p, dict)
2820
+ )
2821
+ if instructions is None:
2822
+ instructions = flat
2823
+ else:
2824
+ input_items.append({"role": "developer", "content": _responses_msg_content(content)})
2825
+ elif role == "assistant":
2826
+ if content:
2827
+ input_items.append({"role": "assistant", "content": _responses_msg_content(content)})
2828
+ for tc in m.get("tool_calls") or []:
2829
+ input_items.append({
2830
+ "type": "function_call",
2831
+ "call_id": tc["id"],
2832
+ "name": tc["function"]["name"],
2833
+ "arguments": tc["function"]["arguments"],
2834
+ })
2835
+ elif role == "tool":
2836
+ input_items.append({
2837
+ "type": "function_call_output",
2838
+ "call_id": m.get("tool_call_id"),
2839
+ "output": m.get("content") or "",
2840
+ })
2841
+ else: # user
2842
+ input_items.append({"role": "user", "content": _responses_msg_content(content)})
2843
+ return instructions, input_items
2844
+
2845
+
2846
+ def _shim_message_from_responses(response) -> _ShimMessage:
2847
+ """Map a Responses result's output items into a Chat-shaped message."""
2848
+ text_parts, tool_calls = [], []
2849
+ for item in (getattr(response, "output", None) or []):
2850
+ itype = getattr(item, "type", None)
2851
+ if itype == "function_call":
2852
+ tool_calls.append(_ShimToolCall(item.call_id, item.name, item.arguments))
2853
+ elif itype == "message":
2854
+ for c in (getattr(item, "content", None) or []):
2855
+ if getattr(c, "type", None) in ("output_text", "text"):
2856
+ text_parts.append(getattr(c, "text", "") or "")
2857
+ # reasoning items are not echoed back (state is rebuilt from history each turn)
2858
+ text = "".join(text_parts) or (getattr(response, "output_text", "") or "")
2859
+ return _ShimMessage(text, tool_calls)
2860
+
2861
+
2862
+ def _shim_usage_from_responses(usage):
2863
+ if usage is None:
2864
+ return None
2865
+ inp = int(getattr(usage, "input_tokens", 0) or 0)
2866
+ out = int(getattr(usage, "output_tokens", 0) or 0)
2867
+ details = getattr(usage, "input_tokens_details", None)
2868
+ cached = int(getattr(details, "cached_tokens", 0) or 0) if details else 0
2869
+ return _ShimUsage(inp, out, cached)
2870
+
2871
+
2872
+ def _orchestrator_turn(client, messages, tools, max_tokens):
2873
+ """One orchestrator LLM turn. Returns (message, usage) shaped like Chat
2874
+ Completions, using either the Chat or Responses API per ERP_CHAT_API.
2875
+ Blocking — call via asyncio.to_thread."""
2876
+ if _use_responses_api():
2877
+ instructions, input_items = _responses_request_from_chat(messages)
2878
+ resp = client.responses.create(
2879
+ model=ORCHESTRATOR_MODEL,
2880
+ instructions=instructions,
2881
+ input=input_items,
2882
+ tools=_responses_tools_from_chat(tools),
2883
+ tool_choice="auto",
2884
+ max_output_tokens=max_tokens,
2885
+ reasoning={"effort": _RESPONSES_REASONING_EFFORT},
2886
+ )
2887
+ return _shim_message_from_responses(resp), _shim_usage_from_responses(getattr(resp, "usage", None))
2888
+ resp = client.chat.completions.create(
2889
+ model=ORCHESTRATOR_MODEL,
2890
+ messages=messages,
2891
+ tools=tools,
2892
+ tool_choice="auto",
2893
+ max_completion_tokens=max_tokens,
2894
+ # gpt-5.6-terra rejects function tools on /v1/chat/completions unless
2895
+ # reasoning_effort is "none" (400 otherwise).
2896
+ reasoning_effort="none",
2897
+ )
2898
+ return resp.choices[0].message, getattr(resp, "usage", None)
2899
+
2900
+
2701
2901
  async def run_thinking_loop(
2702
2902
  messages: list[dict],
2703
2903
  on_event,
@@ -2825,24 +3025,16 @@ async def run_thinking_loop(
2825
3025
  print(f"[chat_llm] provider=openai model={model_name} session_id={session_id or '-'} iter={iteration + 1}", flush=True)
2826
3026
  await on_event({"type": "llm_provider", "provider": "openai", "model": model_name})
2827
3027
  try:
2828
- response = await asyncio.to_thread(
2829
- openai_client.chat.completions.create,
2830
- model=model_name,
2831
- messages=messages,
2832
- tools=build_tools(),
2833
- tool_choice="auto",
2834
- max_completion_tokens=max_completion,
2835
- # gpt-5.6-terra rejects function tools on /v1/chat/completions
2836
- # unless reasoning_effort is "none" (400 otherwise). Tool
2837
- # calls run without hidden reasoning until this loop migrates
2838
- # to the Responses API.
2839
- reasoning_effort="none",
3028
+ # One orchestrator turn via Chat Completions (default) or the
3029
+ # Responses API (ERP_CHAT_API=responses). Returns Chat-shaped
3030
+ # (message, usage) either way, so everything below is unchanged.
3031
+ message, usage = await asyncio.to_thread(
3032
+ _orchestrator_turn, openai_client, messages, build_tools(), max_completion,
2840
3033
  )
2841
3034
  except Exception as e:
2842
3035
  await on_event({"type": "error", "content": f"Error calling LLM: {e}"})
2843
3036
  return
2844
3037
 
2845
- usage = getattr(response, "usage", None)
2846
3038
  demo_limiter.settle(
2847
3039
  reservation_id,
2848
3040
  actual_cost_usd=cost_of_openai_call(model_name, usage),
@@ -2859,9 +3051,6 @@ async def run_thinking_loop(
2859
3051
  if not settled:
2860
3052
  demo_limiter.release(reservation_id)
2861
3053
 
2862
- choice = response.choices[0]
2863
- message = choice.message
2864
-
2865
3054
  if not message.tool_calls:
2866
3055
  content = message.content or ""
2867
3056
  messages.append({"role": "assistant", "content": content})
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.6.16"
3
+ version = "0.6.18"
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