$ cat node-template.py
Odoo RPC
// Call an Odoo 19 model method (search_read, read, search, search_count, create, write, unlink, or a custom method) via the Odoo JSON-2 API. Uses the Odoo connection from account settings, or ODOO_URL / ODOO_API_KEY (and optional ODOO_DATABASE) as workspace secrets.
1"""2Odoo RPC34Call any Odoo 19 model method over the Odoo JSON-2 API.56Credentials are prepared by workspace-service before this container starts —7from the workspace-token owner's saved Odoo connection, or from workspace8secrets of the same names — and reached here through `gais.connectors.odoo`.9This template does NOT read them itself: no `os`, no `requests`, no hand-rolled10auth header. That is why it now passes `pnpm node:test` like every other11template, where it used to carry a documented deny-list exemption.12"""1314import sys, json, traceback1516from gais import connectors17from gais._errors import ConfigError, RemoteError1819# Rows a search returns when the caller does not say. Bounded because this20# node's output is usually read by an LLM, and "all records" is a payload with21# no upper bound. Override per call with `params: {"limit": N}`; `0` means no22# limit, matching Odoo.23DEFAULT_SEARCH_LIMIT = 100242526def parse_json(v, default):27 if v in (None, ""): return default28 if isinstance(v, (dict, list)): return v29 if isinstance(v, str):30 s = v.strip()31 return json.loads(s) if s else default32 return default333435def main():36 try:37 inputs = json.loads(sys.stdin.read()).get("inputs", {})38 model = (inputs.get("model") or "").strip()39 method = (inputs.get("method") or "search_read").strip()40 if method == "__custom__":41 method = (inputs.get("customMethod") or "").strip()42 if not method:43 raise ValueError("Custom method selected but 'Custom method' is empty. "44 "Enter the Odoo method name (e.g. 'name_search').")45 if not model: raise ValueError("model is required (e.g. 'res.partner')")46 if not method: raise ValueError("method is required (e.g. 'search_read')")47 ids = parse_json(inputs.get("ids"), [])48 params = parse_json(inputs.get("params"), {})49 context = parse_json(inputs.get("context"), {})50 timeout = int(inputs.get("timeout") or 60)51 if not isinstance(params, dict):52 raise ValueError("params must be a JSON object of method keyword args")53 body = dict(params)54 # search-family methods require a `domain`; empty list = "all records"55 if method in ("search", "search_read", "search_count") and "domain" not in body:56 body["domain"] = []57 # ...and an unbounded row count is not a sane default for a node whose58 # result is read back by an LLM. `domain: []` above means "all records",59 # so `search_read` on res.partner returned every row with every field:60 # the caller's context window blew and the whole chat turn died with no61 # answer and no actionable error.62 #63 # Injected the same way `domain` is, and overridable the same way — pass64 # `params: {"limit": 500}` for more, or `{"limit": 0}` for genuinely all65 # records (Odoo treats 0 as no limit). Only search/search_read take one;66 # search_count returns a scalar and a limit there would be meaningless.67 if method in ("search", "search_read") and "limit" not in body:68 body["limit"] = DEFAULT_SEARCH_LIMIT69 if ids: body["ids"] = ids70 if context: body["context"] = context7172 data = connectors.odoo.call(model, method, timeout=timeout, **body)7374 output = {"data": data,75 "record_count": len(data) if isinstance(data, list) else None,76 "statusCode": 200, "success": True}77 print(json.dumps(output, indent=2, ensure_ascii=False, default=str))78 except (ConfigError, RemoteError) as e:79 # Surfaced as ValueError-shaped output so the executor's error mapping80 # and the existing "[RUNTIME_ERROR] …" chat card are unchanged. The SDK81 # already phrases both cases for a user: ConfigError names the account82 # settings route, RemoteError carries Odoo's own status and body (a 40483 # "No database is selected" means ODOO_DATABASE is wrong, and that text84 # is the whole diagnosis).85 print(json.dumps({"error": str(e), "errorType": type(e).__name__,86 "traceback": traceback.format_exc()}), file=sys.stderr)87 sys.exit(1)88 except Exception as e:89 print(json.dumps({"error": str(e), "errorType": type(e).__name__,90 "traceback": traceback.format_exc()}), file=sys.stderr)91 sys.exit(1)929394if __name__ == "__main__":95 main()$ git log --oneline
BREAKING: the HTTP call now goes through gais.connectors.odoo instead of os.getenv + requests, so this version REQUIRES an executor image carrying gais.connectors — reseeding the template alone is not enough. Credentials may now come from the user's saved Odoo connection as well as workspace secrets, and the not-connected error names both.
Migrated to multi-file node-templates format; dropped the requests pip-install fallback (requests ships in the executor image).
Migrated to multi-file node-templates format; dropped the requests pip-install fallback (requests ships in the executor image).