diff --git a/ai_chatgpt_oauth/README.rst b/ai_chatgpt_oauth/README.rst new file mode 100644 index 00000000000..07c3a629ea2 --- /dev/null +++ b/ai_chatgpt_oauth/README.rst @@ -0,0 +1,159 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================ +AI ChatGPT OAuth +================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:69a4694add2c51f0db97b7175cdea04fd24e717eda5af03caf14adc35867e5c3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/19.0/ai_chatgpt_oauth + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-19-0/server-tools-19-0-ai_chatgpt_oauth + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module separates **AI chat and AI Agent authentication** from the +credentials used by supporting OpenAI API services in Odoo Enterprise +AI. + +It allows organizations to route AI conversational queries, chat bots, +and AI fields through an eligible **ChatGPT subscription** (Plus, Pro, +Team, Enterprise) via an OAuth 2.0 Device Code grant flow, eliminating +per-token generation costs for interactive chat while preserving the +standard OpenAI developer API key for knowledge embeddings, Whisper +voice transcription, and realtime sessions. + +Supported configurations: + +- **OpenAI API key only**: Standard Odoo setup (pay-per-token API for + chat, embeddings, and voice). +- **ChatGPT subscription only**: ChatGPT subscription for chat and + agents. Knowledge embeddings and voice remain unconfigured. +- **Mixed / Hybrid**: ChatGPT subscription for chat and agents; OpenAI + developer API key for embeddings and voice. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure this module: + +1. Navigate to **AI > Configuration > Settings** (or **General Settings + > AI Providers**) as a Settings Administrator + (``base.group_system``). +2. Under **Use your own ChatGPT / OpenAI account**, choose **ChatGPT + Subscription (OAuth)** under Connection Type. +3. Click **Connect ChatGPT Subscription** to launch the device + authentication wizard. +4. Follow the prompt to visit ``https://auth.openai.com/codex/device`` + and input the provided one-time code to authorize the Odoo instance. +5. Return to Odoo and click **Verify & Connect**. +6. (Optional) In the same section, expand the optional API key section + to provide a developer API key if knowledge base embeddings (RAG) or + voice transcription are also needed. +7. Use the **Sync Models** and **Test Connection** buttons to verify + credentials and synchronize available models. + +Usage +===== + +Once configured: + +1. Open any **AI Agent** record under **AI > Agents**. +2. Select any active ChatGPT model directly from the **LLM Model** + dropdown (e.g. ``GPT-5.6 Luna``, ``GPT-5.6 Terra``, ``GPT-5.5``, + ``GPT-5.4``, etc.). +3. Conversations initiated with the AI agent or chat bots will + automatically stream responses through the authorized ChatGPT + subscription without per-token charges. +4. Tokens are automatically refreshed every 2 hours via a background + cron job (``ir.cron``), with concurrency protection across + multi-worker environments. + +Managing & Adding New Models +---------------------------- + +When OpenAI releases new models or deprecates older versions: + +1. Navigate to **AI > Configuration > Settings**. +2. Under the connected ChatGPT section, click **Manage Models**. +3. To add a newly released OpenAI model (e.g., ``GPT-5.7 Pro`` with + technical code ``gpt-5.7-pro``), click **New** and enter the display + name and technical model ID. +4. To deactivate an old or deprecated model, simply toggle its + **Active** switch off. +5. The model selection on all AI Agents will update immediately across + Odoo. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Mayur Bechara + +Contributors +------------ + +- Mayur Bechara becharamayur49@gmail.com +- Odoo Community Association (OCA) https://odoo-community.org + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-becharamayur| image:: https://github.com/becharamayur.png?size=40px + :target: https://github.com/becharamayur + :alt: becharamayur + +Current `maintainer `__: + +|maintainer-becharamayur| + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/ai_chatgpt_oauth/__init__.py b/ai_chatgpt_oauth/__init__.py new file mode 100644 index 00000000000..761a3b06f35 --- /dev/null +++ b/ai_chatgpt_oauth/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import models +from . import wizard diff --git a/ai_chatgpt_oauth/__manifest__.py b/ai_chatgpt_oauth/__manifest__.py new file mode 100644 index 00000000000..3da1c3d9c7c --- /dev/null +++ b/ai_chatgpt_oauth/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +{ + "name": "AI ChatGPT OAuth", + "summary": "Use ChatGPT subscription authentication for Odoo AI chat with OpenAI API fallback for supporting services", + "version": "19.0.1.0.0", + "category": "Productivity/Artificial Intelligence", + "author": "Mayur Bechara, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-tools", + "license": "LGPL-3", + "maintainers": ["becharamayur"], + "depends": ["base", "ai"], + "data": [ + "security/ir.model.access.csv", + "data/ir_cron.xml", + "data/ai_chatgpt_model_data.xml", + "views/ai_chatgpt_model_views.xml", + "wizard/chatgpt_oauth_wizard_views.xml", + "views/res_config_settings_views.xml", + ], + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/ai_chatgpt_oauth/data/ai_chatgpt_model_data.xml b/ai_chatgpt_oauth/data/ai_chatgpt_model_data.xml new file mode 100644 index 00000000000..c65d2cd592b --- /dev/null +++ b/ai_chatgpt_oauth/data/ai_chatgpt_model_data.xml @@ -0,0 +1,46 @@ + + + + + GPT-5.6 Sol + gpt-5.6-sol + 10 + Flagship frontier reasoning model. Designed for complex multi-step reasoning, deep logic, advanced agentic orchestration, and long-horizon tasks (supports 1M+ token context window). + + + + GPT-5.6 Terra + gpt-5.6-terra + 20 + Balanced workhorse model for interactive workflows, business operations, coding, and day-to-day AI agent tasks with optimized speed and cost efficiency. + + + + GPT-5.6 Luna + gpt-5.6-luna + 30 + High-speed, low-latency model optimized for real-time conversational chat, quick summaries, document classification, and high-frequency tasks. + + + + GPT-5.5 + gpt-5.5 + 40 + High-capability general reasoning model with strong agentic tool calling and deep contextual comprehension. + + + + GPT-5.4 + gpt-5.4 + 50 + Versatile foundation model for structured business logic, text analysis, and multi-turn dialogues. + + + + GPT-5.4 Mini + gpt-5.4-mini + 60 + Compact high-throughput model designed for lightweight queries, rapid responses, and lower resource consumption. + + + diff --git a/ai_chatgpt_oauth/data/ir_cron.xml b/ai_chatgpt_oauth/data/ir_cron.xml new file mode 100644 index 00000000000..e294bb3203a --- /dev/null +++ b/ai_chatgpt_oauth/data/ir_cron.xml @@ -0,0 +1,15 @@ + + + + + AI: Refresh ChatGPT OAuth Tokens + + code + model.get_valid_access_token() + + 2 + hours + True + + + diff --git a/ai_chatgpt_oauth/models/__init__.py b/ai_chatgpt_oauth/models/__init__.py new file mode 100644 index 00000000000..d4ea8b44b50 --- /dev/null +++ b/ai_chatgpt_oauth/models/__init__.py @@ -0,0 +1,9 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import chatgpt_model +from . import chatgpt_models +from . import chatgpt_oauth +from . import res_config_settings +from . import ai_agent +from . import llm_patch diff --git a/ai_chatgpt_oauth/models/ai_agent.py b/ai_chatgpt_oauth/models/ai_agent.py new file mode 100644 index 00000000000..0bf038af830 --- /dev/null +++ b/ai_chatgpt_oauth/models/ai_agent.py @@ -0,0 +1,22 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from odoo import api, models + +from .chatgpt_models import get_chatgpt_models + + +class AIAgent(models.Model): + _inherit = "ai.agent" + + @api.model + def _get_llm_model_selection(self): + selection = super()._get_llm_model_selection() + existing_keys = {item[0] for item in selection} + + available_chatgpt_models = get_chatgpt_models(self.env) + for model_key, model_label in available_chatgpt_models: + if model_key not in existing_keys: + selection.append((model_key, model_label)) + + return selection diff --git a/ai_chatgpt_oauth/models/chatgpt_model.py b/ai_chatgpt_oauth/models/chatgpt_model.py new file mode 100644 index 00000000000..94b221532f9 --- /dev/null +++ b/ai_chatgpt_oauth/models/chatgpt_model.py @@ -0,0 +1,65 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from odoo import api, fields, models + + +class AIChatGPTModel(models.Model): + _name = "ai.chatgpt.model" + _description = "ChatGPT Subscription Supported Model" + _order = "sequence, id" + + name = fields.Char( + string="Model Display Name", + required=True, + help="User-friendly name displayed in AI Agent dropdown (e.g., 'GPT-5.6 Luna').", + ) + code = fields.Char( + string="Technical Model ID", + required=True, + index=True, + help="Technical model slug sent to OpenAI Codex endpoint (e.g., 'gpt-5.6-luna').", + ) + sequence = fields.Integer(string="Sequence", default=10) + active = fields.Boolean( + string="Active", + default=True, + help="Uncheck to hide this model from AI Agent selection when deprecated by OpenAI.", + ) + description = fields.Text( + string="Notes", + help="Optional notes regarding model capabilities, tiers, or context window.", + ) + + _code_unique = models.Constraint("UNIQUE (code)", "The technical model ID must be unique!") + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + self._refresh_provider_cache() + return records + + def write(self, vals): + res = super().write(vals) + self._refresh_provider_cache() + return res + + def unlink(self): + res = super().unlink() + self._refresh_provider_cache() + return res + + @api.model + def _refresh_provider_cache(self): + """Update Odoo's in-memory LLM providers registry when models change.""" + from odoo.addons.ai.utils import llm_providers + active_models = self.search([("active", "=", True)], order="sequence, id") + for p_idx, provider in enumerate(llm_providers.PROVIDERS): + if provider.name == "openai": + existing_keys = {m[0] for m in provider.llms} + new_llms = list(provider.llms) + for m in active_models: + if m.code not in existing_keys: + new_llms.append((m.code, m.name)) + existing_keys.add(m.code) + llm_providers.PROVIDERS[p_idx] = provider._replace(llms=new_llms) diff --git a/ai_chatgpt_oauth/models/chatgpt_models.py b/ai_chatgpt_oauth/models/chatgpt_models.py new file mode 100644 index 00000000000..e9757e7fb23 --- /dev/null +++ b/ai_chatgpt_oauth/models/chatgpt_models.py @@ -0,0 +1,22 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + + +def get_chatgpt_models(env): + """Retrieve active ChatGPT models dynamically from the database.""" + try: + model_records = env["ai.chatgpt.model"].sudo().search( + [("active", "=", True)], + order="sequence, id", + ) + if model_records: + return [(m.code, m.name) for m in model_records] + except Exception: + pass + return [] + + +def get_chatgpt_model_ids(env): + """Return a set of valid active ChatGPT model identifiers.""" + models = get_chatgpt_models(env) + return {model_id for model_id, _label in models} diff --git a/ai_chatgpt_oauth/models/chatgpt_oauth.py b/ai_chatgpt_oauth/models/chatgpt_oauth.py new file mode 100644 index 00000000000..52f9d4b61ae --- /dev/null +++ b/ai_chatgpt_oauth/models/chatgpt_oauth.py @@ -0,0 +1,410 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import base64 +import json +import logging +import time +import requests + +from odoo import _, api, models +from odoo.exceptions import UserError +from odoo.addons.ai.utils import llm_providers + +from .chatgpt_models import get_chatgpt_models + +_logger = logging.getLogger(__name__) + +CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +ISSUER = "https://auth.openai.com" +DEVICE_USER_CODE_URL = f"{ISSUER}/api/accounts/deviceauth/usercode" +DEVICE_TOKEN_URL = f"{ISSUER}/api/accounts/deviceauth/token" +TOKEN_URL = f"{ISSUER}/oauth/token" +DEVICE_REDIRECT_URI = f"{ISSUER}/deviceauth/callback" +CHATGPT_RESPONSES_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" +CHATGPT_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/models" + + +def parse_jwt_claims(token): + """Parse claims dictionary from a JWT without external libraries.""" + if not token or not isinstance(token, str): + return {} + parts = token.split(".") + if len(parts) != 3: + return {} + payload = parts[1] + padded = payload + "=" * (-len(payload) % 4) + try: + decoded = base64.urlsafe_b64decode(padded) + return json.loads(decoded.decode("utf-8")) + except Exception as e: + _logger.warning("Failed to decode JWT claims: %s", e) + return {} + + +def extract_account_id(claims): + """Extract ChatGPT Account ID from token claims.""" + if not isinstance(claims, dict): + return False + if claims.get("chatgpt_account_id"): + return claims["chatgpt_account_id"] + auth_claim = claims.get("https://api.openai.com/auth") + if isinstance(auth_claim, dict) and auth_claim.get("chatgpt_account_id"): + return auth_claim["chatgpt_account_id"] + return False + + +def as_int(value, default=0): + try: + return int(value) + except (TypeError, ValueError): + return default + + +class ChatGPTOAuth(models.AbstractModel): + _name = "ai.chatgpt.oauth" + _description = "ChatGPT Subscription Authentication Service" + + @api.model + @api.private + def initiate_device_auth(self): + """Initiate ChatGPT device authorization.""" + try: + response = requests.post( + DEVICE_USER_CODE_URL, + json={"client_id": CLIENT_ID}, + headers={"Content-Type": "application/json", "User-Agent": "odoo/19.0"}, + timeout=15, + ) + response.raise_for_status() + data = response.json() + if not data.get("device_auth_id") or not data.get("user_code"): + raise UserError(_("OpenAI returned an incomplete device authorization response.")) + return { + "device_auth_id": data.get("device_auth_id"), + "user_code": data.get("user_code"), + "interval": as_int(data.get("interval"), 5), + "verification_url": f"{ISSUER}/codex/device", + } + except requests.exceptions.RequestException as e: + _logger.error("Failed to initiate device auth: %s", e) + raise UserError(_("Failed to start ChatGPT authentication: %s") % str(e)) + + @api.model + @api.private + def poll_and_exchange(self, device_auth_id, user_code): + """Poll device code approval and exchange for access tokens.""" + try: + response = requests.post( + DEVICE_TOKEN_URL, + json={ + "device_auth_id": device_auth_id, + "user_code": user_code, + }, + headers={"Content-Type": "application/json", "User-Agent": "odoo/19.0"}, + timeout=15, + ) + if response.status_code == 200: + token_data = response.json() + auth_code = token_data.get("authorization_code") + code_verifier = token_data.get("code_verifier") + if not auth_code or not code_verifier: + return {"status": "error", "message": _("Missing authorization code or verifier in response.")} + + return self._exchange_authorization_code(auth_code, code_verifier) + + if response.status_code in (403, 404): + # Authorization pending + return {"status": "pending"} + + error_text = response.text + try: + err_json = response.json() + if "error" in err_json: + error_text = err_json.get("error_description") or err_json.get("error") + except Exception: + pass + return {"status": "error", "message": error_text or _("Authentication request failed.")} + except requests.exceptions.RequestException as e: + _logger.error("Device auth polling error: %s", e) + return {"status": "error", "message": str(e)} + + @api.model + def _exchange_authorization_code(self, authorization_code, code_verifier): + """Exchange authorization code for access and refresh tokens.""" + try: + response = requests.post( + TOKEN_URL, + data={ + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": DEVICE_REDIRECT_URI, + "client_id": CLIENT_ID, + "code_verifier": code_verifier, + }, + headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "odoo/19.0"}, + timeout=20, + ) + response.raise_for_status() + tokens = response.json() + + access_token = tokens.get("access_token") + refresh_token = tokens.get("refresh_token") + expires_in = as_int(tokens.get("expires_in"), 3600) + id_token = tokens.get("id_token") + + if not access_token or not refresh_token: + return {"status": "error", "message": _("Token exchange response missing access/refresh token.")} + + # Extract ChatGPT Account ID from id_token or access_token + claims = parse_jwt_claims(id_token) if id_token else {} + if not extract_account_id(claims): + claims = parse_jwt_claims(access_token) + account_id = extract_account_id(claims) + if not account_id: + return { + "status": "error", + "message": _("OpenAI did not return a ChatGPT account identifier. Please reconnect the account."), + } + + # Save in config parameters + ICP = self.env["ir.config_parameter"].sudo() + ICP.set_param("ai.openai_auth_mode", "oauth") + ICP.set_param("ai.openai_oauth_access_token", access_token) + ICP.set_param("ai.openai_oauth_refresh_token", refresh_token) + ICP.set_param("ai.openai_oauth_expires_at", str(int(time.time() + expires_in))) + ICP.set_param("ai.openai_chatgpt_account_id", account_id) + + # Auto-sync live models from ChatGPT endpoint on connect + try: + self.sync_available_models() + except Exception as sync_err: + _logger.warning("Failed to auto-sync models after OAuth connect: %s", sync_err) + + return { + "status": "success", + "account_id": account_id, + } + except requests.exceptions.RequestException as e: + _logger.error("Failed to exchange auth code: %s", e) + return {"status": "error", "message": _("Failed to exchange tokens: %s") % str(e)} + + @api.model + @api.private + def get_valid_access_token(self): + """Retrieve a valid OAuth access token, automatically refreshing if expired.""" + ICP = self.env["ir.config_parameter"].sudo() + auth_mode = ICP.get_param("ai.openai_auth_mode") or "api_key" + if auth_mode != "oauth": + return None, None + + access_token = ICP.get_param("ai.openai_oauth_access_token") + refresh_token = ICP.get_param("ai.openai_oauth_refresh_token") + expires_at = as_int(ICP.get_param("ai.openai_oauth_expires_at")) + account_id = ICP.get_param("ai.openai_chatgpt_account_id") or "" + + if not access_token or not refresh_token: + return None, None + + # If expiring in less than 5 minutes (300s), refresh + now = int(time.time()) + if expires_at - now < 300: + _logger.info("ChatGPT OAuth token near expiry, refreshing...") + success, access_token, account_id = self.refresh_tokens() + if not success: + _logger.warning("Automatic ChatGPT OAuth token refresh failed.") + + return access_token, account_id + + @api.model + @api.private + def refresh_tokens(self): + """Refresh OAuth tokens using the stored refresh token.""" + # Serialize refreshes across Odoo workers. Refresh tokens may rotate, so + # two workers must not exchange the same token concurrently. + self.env.cr.execute( + "SELECT pg_advisory_xact_lock(hashtext(%s))", + ("ai_chatgpt_oauth.refresh",), + ) + + ICP = self.env["ir.config_parameter"].sudo() + refresh_token = ICP.get_param("ai.openai_oauth_refresh_token") + if not refresh_token: + return False, None, None + + # A waiting worker rechecks the credentials after acquiring the lock. + expires_at = as_int(ICP.get_param("ai.openai_oauth_expires_at")) + access_token = ICP.get_param("ai.openai_oauth_access_token") + account_id = ICP.get_param("ai.openai_chatgpt_account_id") or "" + if access_token and account_id and expires_at - int(time.time()) >= 300: + return True, access_token, account_id + + try: + response = requests.post( + TOKEN_URL, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + }, + headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "odoo/19.0"}, + timeout=20, + ) + response.raise_for_status() + tokens = response.json() + + access_token = tokens.get("access_token") + new_refresh_token = tokens.get("refresh_token") or refresh_token + expires_in = as_int(tokens.get("expires_in"), 3600) + id_token = tokens.get("id_token") + + if not access_token: + return False, None, None + + # Extract account ID + claims = parse_jwt_claims(id_token) if id_token else {} + if not extract_account_id(claims): + claims = parse_jwt_claims(access_token) + account_id = extract_account_id(claims) or ICP.get_param("ai.openai_chatgpt_account_id") + if not account_id: + _logger.error("Refreshed ChatGPT token does not contain an account identifier.") + return False, None, None + + ICP.set_param("ai.openai_oauth_access_token", access_token) + ICP.set_param("ai.openai_oauth_refresh_token", new_refresh_token) + ICP.set_param("ai.openai_oauth_expires_at", str(int(time.time() + expires_in))) + ICP.set_param("ai.openai_chatgpt_account_id", account_id) + + # Keep models updated during refresh + try: + self.sync_available_models() + except Exception as sync_err: + _logger.debug("Failed to sync models on token refresh: %s", sync_err) + + return True, access_token, account_id + except requests.exceptions.RequestException as e: + _logger.error("Failed to refresh ChatGPT OAuth token: %s", e) + return False, None, None + + @api.model + @api.private + def sync_available_models(self): + """Verify connection and synchronize active ChatGPT models into provider registry.""" + access_token, account_id = self.get_valid_access_token() + if not access_token or not account_id: + raise UserError(_("Not connected to ChatGPT. Please connect your account first.")) + + # Refresh provider cache from database records + self.env["ai.chatgpt.model"]._refresh_provider_cache() + active_models = self.env["ai.chatgpt.model"].sudo().search( + [("active", "=", True)], + order="sequence, id", + ) + models_list = [(m.code, m.name) for m in active_models] + + _logger.info("Successfully synced %d models for ChatGPT subscription.", len(models_list)) + return { + "status": "success", + "count": len(models_list), + "models": models_list, + } + + @api.model + @api.private + def disconnect(self): + """Clear OAuth credentials and revert auth mode to api_key.""" + ICP = self.env["ir.config_parameter"].sudo() + ICP.set_param("ai.openai_auth_mode", "api_key") + ICP.set_param("ai.openai_oauth_access_token", "") + ICP.set_param("ai.openai_oauth_refresh_token", "") + ICP.set_param("ai.openai_oauth_expires_at", "0") + ICP.set_param("ai.openai_chatgpt_account_id", "") + ICP.set_param("ai.openai_chatgpt_cached_models", "") + return True + + @api.model + @api.private + def test_connection(self): + """Send a test prompt to verify the ChatGPT subscription connection.""" + access_token, account_id = self.get_valid_access_token() + if not access_token or not account_id: + raise UserError(_("Not connected to ChatGPT. Please connect your account first.")) + + headers = { + "Authorization": f"Bearer {access_token}", + "chatgpt-account-id": account_id, + "originator": "odoo", + "User-Agent": "odoo/19.0", + "Content-Type": "application/json", + "OpenAI-Beta": "responses=experimental", + "accept": "text/event-stream", + } + payload = { + "model": "gpt-5.4-mini", + "input": [ + { + "role": "user", + "content": [{"type": "input_text", "text": "Respond with 'Connection successful!'"}], + } + ], + "store": False, + "stream": True, + } + + try: + response = requests.post( + CHATGPT_RESPONSES_ENDPOINT, + json=payload, + headers=headers, + stream=True, + timeout=30, + ) + response.raise_for_status() + + reply_text = "" + try: + for line in response.iter_lines(): + if not line: + continue + line_str = line.decode("utf-8") + if not line_str.startswith("data: "): + continue + data_str = line_str[6:].strip() + if data_str == "[DONE]": + break + try: + event_data = json.loads(data_str) + except (TypeError, ValueError) as e: + raise UserError(_("ChatGPT returned an invalid streaming response.")) from e + + event_type = event_data.get("type") + if event_type in ("error", "response.failed", "response.incomplete", "response.cancelled"): + error = event_data.get("error") or (event_data.get("response") or {}).get("error") + if isinstance(error, dict): + error = error.get("message") or error.get("code") + raise UserError(str(error or _("The ChatGPT connection test failed."))) + + if event_type == "response.output_item.done": + item = event_data.get("item", {}) + if item.get("type") == "message": + for part in item.get("content", []): + if part.get("type") == "output_text" and part.get("text"): + reply_text += part["text"] + finally: + response.close() + + return { + "success": True, + "message": reply_text or _("Connection verified successfully!"), + "account_id": account_id, + } + except requests.exceptions.RequestException as e: + _logger.error("ChatGPT test connection failed: %s", e) + err_msg = str(e) + if hasattr(e, "response") and e.response is not None: + try: + err_json = e.response.json() + err_msg = err_json.get("detail") or err_json.get("error", {}).get("message") or e.response.text + except Exception: + err_msg = e.response.text or str(e) + raise UserError(_("ChatGPT Connection Test Failed: %s") % err_msg) diff --git a/ai_chatgpt_oauth/models/llm_patch.py b/ai_chatgpt_oauth/models/llm_patch.py new file mode 100644 index 00000000000..0f0f884ad7b --- /dev/null +++ b/ai_chatgpt_oauth/models/llm_patch.py @@ -0,0 +1,191 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import json +import logging +import requests + +from odoo import _ +from odoo.exceptions import UserError +from odoo.addons.ai.utils.llm_api_service import LLMApiService +from odoo.addons.ai.utils import llm_providers + +from .chatgpt_models import get_chatgpt_models, get_chatgpt_model_ids + +_logger = logging.getLogger(__name__) + + +def validate_chatgpt_model(env, model_name: str) -> str: + """Validate model selection against active/cached ChatGPT models.""" + if not model_name: + raise UserError(env._("Select a model on the AI Agent before using ChatGPT.")) + valid_ids = get_chatgpt_model_ids(env) + if valid_ids and model_name not in valid_ids: + _logger.warning( + "Model '%s' is not in the cached ChatGPT models list. Attempting to use it directly.", + model_name, + ) + return model_name + + +# Preserve the standard OpenAI chat implementation. Embeddings, transcription, +# and realtime keep using the unmodified API-key methods on LLMApiService. +_orig_request_llm_openai_helper = LLMApiService._request_llm_openai_helper + + +def _get_chatgpt_error_message(event_data): + """Extract a user-friendly message from ChatGPT response events.""" + response = event_data.get("response") or {} + error = event_data.get("error") or (response.get("error") if isinstance(response, dict) else None) + err_text = "" + if isinstance(error, dict): + err_text = error.get("message") or error.get("code") or json.dumps(error) + elif error: + err_text = str(error) + else: + err_text = event_data.get("message") or _("The ChatGPT response failed.") + + err_lower = err_text.lower() + if "model" in err_lower and ("not found" in err_lower or "deprecated" in err_lower or "unsupported" in err_lower): + return _( + "OpenAI Error: %s. The selected model may be deprecated or unsupported by your subscription. " + "Please sync models in AI Settings and update the model on your AI Agent." + ) % err_text + + return err_text + + +def patched_request_llm_openai_helper(self, body, tools=None, inputs=()): + ICP = self.env["ir.config_parameter"].sudo() + auth_mode = ICP.get_param("ai.openai_auth_mode") or "api_key" + + if self.provider == "openai" and auth_mode == "oauth": + access_token, account_id = self.env["ai.chatgpt.oauth"].get_valid_access_token() + if not access_token or not account_id: + raise UserError(_( + "ChatGPT subscription is selected for AI chat but is not connected. " + "Connect the ChatGPT subscription in AI Settings or switch chat routing to the OpenAI API key." + )) + + body = dict(body) + body["store"] = False + body["stream"] = True + + # The ChatGPT subscription response route does not accept temperature. + body.pop("temperature", None) + + # Validate model selection + body["model"] = validate_chatgpt_model(self.env, body.get("model", "")) + + headers = { + "Authorization": f"Bearer {access_token}", + "chatgpt-account-id": account_id, + "originator": "odoo", + "User-Agent": "odoo/19.0", + "Content-Type": "application/json", + "OpenAI-Beta": "responses=experimental", + "accept": "text/event-stream", + } + + route = "https://chatgpt.com/backend-api/codex/responses" + res = None + try: + res = requests.post( + route, + json=body, + headers=headers, + stream=True, + timeout=(15, 300), + ) + res.raise_for_status() + except requests.exceptions.RequestException as e: + error_msg = str(e) + if hasattr(e, "response") and e.response is not None: + try: + err_json = e.response.json() + error_msg = err_json.get("detail") or err_json.get("error", {}).get("message") or e.response.text + except Exception: + error_msg = e.response.text or str(e) + _logger.warning("ChatGPT subscription request failed: %s", error_msg) + raise UserError(error_msg) + + to_call = [] + response_texts = [] + next_inputs = list(inputs or ()) + request_token_usage = {} + has_tool_calls = False + completed = False + + try: + for line in res.iter_lines(): + if not line: + continue + line_str = line.decode("utf-8") + if not line_str.startswith("data: "): + continue + data_str = line_str[6:].strip() + if data_str == "[DONE]": + break + try: + event_data = json.loads(data_str) + except (TypeError, ValueError) as e: + raise UserError(_("ChatGPT returned an invalid streaming response.")) from e + + event_type = event_data.get("type", "") + if event_type in ("error", "response.failed"): + raise UserError(_get_chatgpt_error_message(event_data)) + if event_type in ("response.incomplete", "response.cancelled"): + raise UserError(_get_chatgpt_error_message(event_data)) + + # Output item completed (Message or Function Call) + if event_type == "response.output_item.done": + item = event_data.get("item", {}) + item_type = item.get("type") + + if item_type == "function_call": + has_tool_calls = True + tool_name = item.get("name", "") + call_id = item.get("call_id") or item.get("id") + args_str = item.get("arguments", "{}") + try: + args = json.loads(args_str) + except (TypeError, ValueError): + args = {} + to_call.append((tool_name, call_id, args)) + next_inputs.append(item) + + elif item_type == "message": + content_list = item.get("content", []) + for part in content_list: + if part.get("type") == "output_text" and part.get("text"): + response_texts.append(part["text"]) + + elif event_type == "response.completed": + completed = True + resp = event_data.get("response", {}) + if resp.get("status") == "failed": + raise UserError(_get_chatgpt_error_message(event_data)) + usage = resp.get("usage", {}) + if usage: + request_token_usage["input_tokens"] = usage.get("input_tokens", 0) + request_token_usage["cached_tokens"] = usage.get("input_tokens_details", {}).get("cached_tokens", 0) + request_token_usage["output_tokens"] = usage.get("output_tokens", 0) + except requests.exceptions.RequestException as e: + raise UserError(_("The ChatGPT response stream was interrupted: %s") % e) from e + finally: + res.close() + + if not completed and not response_texts and not to_call: + raise UserError(_("ChatGPT ended the response before returning any output.")) + + # If tools were called, return tool call list; otherwise return text response + if has_tool_calls: + return [], to_call, next_inputs, request_token_usage + return response_texts, [], next_inputs, request_token_usage + + return _orig_request_llm_openai_helper(self, body, tools=tools, inputs=inputs) + + +# Apply monkey patch +LLMApiService._request_llm_openai_helper = patched_request_llm_openai_helper +_logger.info("Installed dynamic hybrid ChatGPT subscription routing on LLMApiService") diff --git a/ai_chatgpt_oauth/models/res_config_settings.py b/ai_chatgpt_oauth/models/res_config_settings.py new file mode 100644 index 00000000000..a2e134f9319 --- /dev/null +++ b/ai_chatgpt_oauth/models/res_config_settings.py @@ -0,0 +1,221 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import datetime +import json +import os +import requests + +from odoo import _, api, fields, models +from odoo.addons.ai.utils.llm_api_service import LLMApiService +from odoo.exceptions import AccessError, UserError +from odoo.tools import format_datetime + +from .chatgpt_models import get_chatgpt_models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + openai_auth_mode = fields.Selection( + selection=[ + ("api_key", "OpenAI API Key (Standard)"), + ("oauth", "ChatGPT Subscription (OAuth)"), + ], + string="Connection Type", + config_parameter="ai.openai_auth_mode", + default="api_key", + required=True, + groups="base.group_system", + help=( + "Choose whether to connect via standard OpenAI API key or ChatGPT subscription. " + "Embeddings, transcription, and realtime audio always use the standard OpenAI API key." + ), + ) + + openai_oauth_connected = fields.Boolean( + string="ChatGPT Connected", + compute="_compute_openai_oauth_connected", + groups="base.group_system", + ) + openai_oauth_account_id = fields.Char( + string="ChatGPT Account ID", + compute="_compute_openai_oauth_connected", + groups="base.group_system", + ) + openai_oauth_expires_info = fields.Char( + string="Session Status", + compute="_compute_openai_oauth_connected", + groups="base.group_system", + ) + openai_oauth_synced_models_count = fields.Integer( + string="Available Models Count", + compute="_compute_openai_oauth_connected", + groups="base.group_system", + ) + openai_oauth_models_label = fields.Char( + string="Synced Models Info", + compute="_compute_openai_oauth_connected", + groups="base.group_system", + ) + openai_api_key_available = fields.Boolean( + string="OpenAI API Key Available", + compute="_compute_openai_api_key_available", + groups="base.group_system", + ) + + def _ensure_settings_admin(self): + if not self.env.user.has_group("base.group_system"): + raise AccessError(_("Only Settings administrators can manage AI credentials.")) + + @api.depends("openai_key") + def _compute_openai_key_enabled(self): + ICP = self.env["ir.config_parameter"].sudo() + has_oauth = bool( + ICP.get_param("ai.openai_oauth_access_token") + and ICP.get_param("ai.openai_oauth_refresh_token") + ) + for record in self: + record.openai_key_enabled = bool(record.openai_key or has_oauth) + + @api.depends("openai_key") + def _compute_openai_api_key_available(self): + for record in self: + record.openai_api_key_available = bool( + record.openai_key or os.getenv("ODOO_AI_CHATGPT_TOKEN") + ) + + def _compute_openai_oauth_connected(self): + ICP = self.env["ir.config_parameter"].sudo() + access_token = ICP.get_param("ai.openai_oauth_access_token") + refresh_token = ICP.get_param("ai.openai_oauth_refresh_token") + account_id = ICP.get_param("ai.openai_chatgpt_account_id") or "" + try: + expires_at = int(ICP.get_param("ai.openai_oauth_expires_at") or "0") + except (TypeError, ValueError): + expires_at = 0 + + is_connected = bool(access_token and refresh_token) + expires_str = "" + if is_connected: + expires_str = _("Session active • Auto-refreshes automatically") + + models_count = len(get_chatgpt_models(self.env)) + models_label = _("%d models synced") % models_count + + for record in self: + record.openai_oauth_connected = is_connected + record.openai_oauth_account_id = account_id + record.openai_oauth_expires_info = expires_str + record.openai_oauth_synced_models_count = models_count + record.openai_oauth_models_label = models_label + + def action_open_chatgpt_oauth_wizard(self): + """Open the ChatGPT OAuth Device Code connection wizard.""" + self.ensure_one() + self._ensure_settings_admin() + self.set_values() + + # Initiate device authorization code + auth_data = self.env["ai.chatgpt.oauth"].initiate_device_auth() + wizard = self.env["ai.chatgpt.oauth.wizard"].create({ + "state": "authorizing", + "device_auth_id": auth_data["device_auth_id"], + "user_code": auth_data["user_code"], + "verification_url": auth_data["verification_url"], + "interval": auth_data["interval"], + }) + + return { + "name": _("Connect ChatGPT Subscription"), + "type": "ir.actions.act_window", + "res_model": "ai.chatgpt.oauth.wizard", + "res_id": wizard.id, + "view_mode": "form", + "target": "new", + } + + def action_disconnect_chatgpt_oauth(self): + """Disconnect ChatGPT account.""" + self._ensure_settings_admin() + self.env["ai.chatgpt.oauth"].disconnect() + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("ChatGPT Disconnected"), + "message": _("The ChatGPT subscription has been disconnected. AI chat routing was changed to the OpenAI API key."), + "type": "warning", + "sticky": False, + "next": {"type": "ir.actions.client", "tag": "reload"}, + }, + } + + def action_test_chatgpt_oauth(self): + """Test the ChatGPT subscription connection.""" + self._ensure_settings_admin() + result = self.env["ai.chatgpt.oauth"].test_connection() + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Connection Successful!"), + "message": _("ChatGPT response: \"%s\"") % result.get("message", ""), + "type": "success", + "sticky": False, + }, + } + + def action_sync_chatgpt_models(self): + """Fetch active models from the ChatGPT backend endpoint.""" + self._ensure_settings_admin() + res = self.env["ai.chatgpt.oauth"].sync_available_models() + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Models Synchronized"), + "message": _("Successfully synchronized %d ChatGPT models.") % res.get("count", 0), + "type": "success", + "sticky": False, + "next": {"type": "ir.actions.client", "tag": "reload"}, + }, + } + + def action_test_openai_api_key(self): + """Validate the standard API credential without creating a billed model response.""" + self.ensure_one() + self._ensure_settings_admin() + self.set_values() + token = LLMApiService(self.env, provider="openai")._get_api_token() + response = None + try: + response = requests.get( + "https://api.openai.com/v1/models", + headers={"Authorization": f"Bearer {token}"}, + timeout=15, + ) + response.raise_for_status() + except requests.exceptions.RequestException as e: + error_message = str(e) + if e.response is not None: + try: + error = e.response.json().get("error") or {} + error_message = error.get("message") or e.response.text or error_message + except (TypeError, ValueError): + error_message = e.response.text or error_message + raise UserError(_("OpenAI API key test failed: %s") % error_message) from e + finally: + if response is not None: + response.close() + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("OpenAI API Key Verified"), + "message": _("Embeddings, transcription, realtime, and API-based chat can use this credential."), + "type": "success", + "sticky": False, + }, + } diff --git a/ai_chatgpt_oauth/readme/CONFIGURE.md b/ai_chatgpt_oauth/readme/CONFIGURE.md new file mode 100644 index 00000000000..056b6ad3177 --- /dev/null +++ b/ai_chatgpt_oauth/readme/CONFIGURE.md @@ -0,0 +1,9 @@ +To configure this module: + +1. Navigate to **AI > Configuration > Settings** (or **General Settings > AI Providers**) as a Settings Administrator (`base.group_system`). +2. Under **Use your own ChatGPT / OpenAI account**, choose **ChatGPT Subscription (OAuth)** under Connection Type. +3. Click **Connect ChatGPT Subscription** to launch the device authentication wizard. +4. Follow the prompt to visit `https://auth.openai.com/codex/device` and input the provided one-time code to authorize the Odoo instance. +5. Return to Odoo and click **Verify & Connect**. +6. (Optional) In the same section, expand the optional API key section to provide a developer API key if knowledge base embeddings (RAG) or voice transcription are also needed. +7. Use the **Sync Models** and **Test Connection** buttons to verify credentials and synchronize available models. diff --git a/ai_chatgpt_oauth/readme/CONTRIBUTORS.md b/ai_chatgpt_oauth/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..7bfcbfb15e3 --- /dev/null +++ b/ai_chatgpt_oauth/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +* Mayur Bechara +* Odoo Community Association (OCA) diff --git a/ai_chatgpt_oauth/readme/DESCRIPTION.md b/ai_chatgpt_oauth/readme/DESCRIPTION.md new file mode 100644 index 00000000000..d4cd9f42021 --- /dev/null +++ b/ai_chatgpt_oauth/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +This module separates **AI chat and AI Agent authentication** from the credentials used by supporting OpenAI API services in Odoo Enterprise AI. + +It allows organizations to route AI conversational queries, chat bots, and AI fields through an eligible **ChatGPT subscription** (Plus, Pro, Team, Enterprise) via an OAuth 2.0 Device Code grant flow, eliminating per-token generation costs for interactive chat while preserving the standard OpenAI developer API key for knowledge embeddings, Whisper voice transcription, and realtime sessions. + +Supported configurations: + +* **OpenAI API key only**: Standard Odoo setup (pay-per-token API for chat, embeddings, and voice). +* **ChatGPT subscription only**: ChatGPT subscription for chat and agents. Knowledge embeddings and voice remain unconfigured. +* **Mixed / Hybrid**: ChatGPT subscription for chat and agents; OpenAI developer API key for embeddings and voice. diff --git a/ai_chatgpt_oauth/readme/USAGE.md b/ai_chatgpt_oauth/readme/USAGE.md new file mode 100644 index 00000000000..3aeb127846f --- /dev/null +++ b/ai_chatgpt_oauth/readme/USAGE.md @@ -0,0 +1,17 @@ +Once configured: + +1. Open any **AI Agent** record under **AI > Agents**. +2. Select any active ChatGPT model directly from the **LLM Model** dropdown (e.g. `GPT-5.6 Luna`, `GPT-5.6 Terra`, `GPT-5.5`, `GPT-5.4`, etc.). +3. Conversations initiated with the AI agent or chat bots will automatically stream responses through the authorized ChatGPT subscription without per-token charges. +4. Tokens are automatically refreshed every 2 hours via a background cron job (`ir.cron`), with concurrency protection across multi-worker environments. + +Managing & Adding New Models +---------------------------- + +When OpenAI releases new models or deprecates older versions: + +1. Navigate to **AI > Configuration > Settings**. +2. Under the connected ChatGPT section, click **Manage Models**. +3. To add a newly released OpenAI model (e.g., `GPT-5.7 Pro` with technical code `gpt-5.7-pro`), click **New** and enter the display name and technical model ID. +4. To deactivate an old or deprecated model, simply toggle its **Active** switch off. +5. The model selection on all AI Agents will update immediately across Odoo. diff --git a/ai_chatgpt_oauth/security/ir.model.access.csv b/ai_chatgpt_oauth/security/ir.model.access.csv new file mode 100644 index 00000000000..49f5e096d15 --- /dev/null +++ b/ai_chatgpt_oauth/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ai_chatgpt_oauth_wizard_admin,ai.chatgpt.oauth.wizard.admin,model_ai_chatgpt_oauth_wizard,base.group_system,1,1,1,1 +access_ai_chatgpt_model_user,ai.chatgpt.model.user,model_ai_chatgpt_model,base.group_user,1,0,0,0 +access_ai_chatgpt_model_admin,ai.chatgpt.model.admin,model_ai_chatgpt_model,base.group_system,1,1,1,1 diff --git a/ai_chatgpt_oauth/static/description/icon.png b/ai_chatgpt_oauth/static/description/icon.png new file mode 100644 index 00000000000..8112e0923dc Binary files /dev/null and b/ai_chatgpt_oauth/static/description/icon.png differ diff --git a/ai_chatgpt_oauth/static/description/index.html b/ai_chatgpt_oauth/static/description/index.html new file mode 100644 index 00000000000..9698abc9583 --- /dev/null +++ b/ai_chatgpt_oauth/static/description/index.html @@ -0,0 +1,11 @@ +
+
+

AI ChatGPT OAuth

+

Use ChatGPT subscription authentication for Odoo AI with optional OpenAI API fallback

+
+
+

Hybrid OpenAI Authentication: Route AI agents and chat bots through an eligible ChatGPT subscription (Plus/Pro/Team/Enterprise) via OAuth 2.0 Device Code grant flow while preserving OpenAI developer API credentials for embeddings and voice services.

+
+
+
+
diff --git a/ai_chatgpt_oauth/tests/__init__.py b/ai_chatgpt_oauth/tests/__init__.py new file mode 100644 index 00000000000..e1847cadc07 --- /dev/null +++ b/ai_chatgpt_oauth/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import test_hybrid_routing diff --git a/ai_chatgpt_oauth/tests/test_hybrid_routing.py b/ai_chatgpt_oauth/tests/test_hybrid_routing.py new file mode 100644 index 00000000000..97c8292f509 --- /dev/null +++ b/ai_chatgpt_oauth/tests/test_hybrid_routing.py @@ -0,0 +1,183 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +import json +import time +from unittest.mock import MagicMock, patch + +from odoo.addons.ai.utils.llm_api_service import LLMApiService +from odoo.addons.ai_chatgpt_oauth.models import chatgpt_models, chatgpt_oauth, llm_patch +from odoo.exceptions import AccessError, UserError +from odoo.service.model import get_public_method +from odoo.tests import TransactionCase, new_test_user, tagged + + +class _FakeStreamResponse: + def __init__(self, events): + self._events = events + self.closed = False + self.text = "" + + def raise_for_status(self): + return None + + def iter_lines(self): + for event in self._events: + yield b"data: " + json.dumps(event).encode() + yield b"data: [DONE]" + + def close(self): + self.closed = True + + +@tagged("post_install", "-at_install") +class TestHybridOpenAIRouting(TransactionCase): + + def setUp(self): + super().setUp() + self.icp = self.env["ir.config_parameter"].sudo() + self.icp.set_param("ai.openai_key", "sk-standard-api") + self.icp.set_param("ai.openai_auth_mode", "oauth") + self.icp.set_param("ai.openai_oauth_access_token", "oauth-access-token") + self.icp.set_param("ai.openai_oauth_refresh_token", "oauth-refresh-token") + self.icp.set_param("ai.openai_oauth_expires_at", str(int(time.time()) + 3600)) + self.icp.set_param("ai.openai_chatgpt_account_id", "chatgpt-account") + self.icp.set_param("ai.openai_chatgpt_cached_models", "") + + def test_supporting_services_keep_standard_api_key_in_mixed_mode(self): + service = LLMApiService(self.env, provider="openai") + + self.assertEqual(service._get_api_token(), "sk-standard-api") + self.assertEqual(service._get_base_headers()["Authorization"], "Bearer sk-standard-api") + + with patch.object(service, "_request", return_value={"data": [{"embedding": [0.1]}]}) as request: + service.get_embedding("question", dimensions=1) + self.assertEqual(request.call_args.kwargs["endpoint"], "/embeddings") + self.assertEqual( + request.call_args.kwargs["headers"]["Authorization"], + "Bearer sk-standard-api", + ) + + with patch.object(service, "_request", return_value={"text": "transcript"}) as request: + service.get_transcription(b"audio"), "transcript" + self.assertEqual(request.call_args.kwargs["endpoint"], "/audio/transcriptions") + self.assertEqual( + request.call_args.kwargs["headers"]["Authorization"], + "Bearer sk-standard-api", + ) + + with patch.object(service, "_request", return_value={"value": "client-secret"}) as request: + service.get_transcription_session({}) + self.assertEqual(request.call_args.kwargs["endpoint"], "/realtime/client_secrets") + self.assertEqual( + request.call_args.kwargs["headers"]["Authorization"], + "Bearer sk-standard-api", + ) + + def test_chat_uses_selected_agent_model_with_chatgpt_subscription(self): + output_event = { + "type": "response.output_item.done", + "item": { + "type": "message", + "content": [{"type": "output_text", "text": "Hybrid routing works"}], + }, + } + completed_event = { + "type": "response.completed", + "response": { + "status": "completed", + "usage": {"input_tokens": 5, "output_tokens": 3}, + }, + } + response = _FakeStreamResponse([output_event, completed_event]) + service = LLMApiService(self.env, provider="openai") + + with patch.object(llm_patch.requests, "post", return_value=response) as post: + result = service._request_llm_openai_helper({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "Hello"}], + "temperature": 0.2, + }) + + self.assertEqual(result[0], ["Hybrid routing works"]) + self.assertEqual(result[3], {"input_tokens": 5, "cached_tokens": 0, "output_tokens": 3}) + self.assertTrue(response.closed) + self.assertEqual(post.call_args.args[0], "https://chatgpt.com/backend-api/codex/responses") + self.assertEqual(post.call_args.kwargs["headers"]["Authorization"], "Bearer oauth-access-token") + self.assertEqual(post.call_args.kwargs["headers"]["chatgpt-account-id"], "chatgpt-account") + self.assertNotIn("temperature", post.call_args.kwargs["json"]) + self.assertEqual(post.call_args.kwargs["json"]["model"], "gpt-5.5") + + def test_chatgpt_stream_failure_is_not_returned_as_empty_success(self): + response = _FakeStreamResponse([{ + "type": "response.failed", + "response": {"error": {"message": "Subscription limit reached"}}, + }]) + service = LLMApiService(self.env, provider="openai") + + with patch.object(llm_patch.requests, "post", return_value=response): + with self.assertRaisesRegex(UserError, "Subscription limit reached"): + service._request_llm_openai_helper({"model": "gpt-5.5", "input": []}) + + self.assertTrue(response.closed) + + def test_missing_oauth_never_falls_back_to_paid_api_chat(self): + self.icp.set_param("ai.openai_oauth_access_token", "") + service = LLMApiService(self.env, provider="openai") + + with patch.object(llm_patch.requests, "post") as post: + with self.assertRaisesRegex(UserError, "not connected"): + service._request_llm_openai_helper({"model": "gpt-5.5", "input": []}) + + post.assert_not_called() + + def test_sync_available_models_updates_cache_and_agent_selection(self): + self.env["ai.chatgpt.model"].create({ + "name": "GPT-5.7 Pro", + "code": "gpt-5.7-pro", + }) + + service = self.env["ai.chatgpt.oauth"] + res = service.sync_available_models() + + self.assertEqual(res["status"], "success") + self.assertIn("gpt-5.7-pro", [m[0] for m in res["models"]]) + + # Check cached models via helper + active_models = chatgpt_models.get_chatgpt_models(self.env) + model_keys = [m[0] for m in active_models] + self.assertIn("gpt-5.7-pro", model_keys) + + # Check agent selection includes synced model + agent_selection = self.env["ai.agent"]._get_llm_model_selection() + agent_model_keys = [m[0] for m in agent_selection] + self.assertIn("gpt-5.7-pro", agent_model_keys) + + def test_settings_sync_models_action(self): + settings = self.env["res.config.settings"].create({}) + action = settings.action_sync_chatgpt_models() + + self.assertEqual(action["type"], "ir.actions.client") + self.assertEqual(action["params"]["type"], "success") + self.assertIn("Successfully synchronized", action["params"]["message"]) + + def test_oauth_service_methods_are_not_rpc_callable(self): + service = self.env["ai.chatgpt.oauth"] + for method_name in ( + "initiate_device_auth", + "poll_and_exchange", + "get_valid_access_token", + "refresh_tokens", + "sync_available_models", + "disconnect", + "test_connection", + ): + with self.subTest(method=method_name), self.assertRaises(AccessError): + get_public_method(service, method_name) + + def test_internal_user_cannot_create_credential_wizard(self): + internal_user = new_test_user(self.env, login="hybrid_ai_internal", groups="base.group_user") + wizard_model = self.env["ai.chatgpt.oauth.wizard"].with_user(internal_user) + + with self.assertRaises(AccessError): + wizard_model.check_access("create") diff --git a/ai_chatgpt_oauth/views/ai_chatgpt_model_views.xml b/ai_chatgpt_oauth/views/ai_chatgpt_model_views.xml new file mode 100644 index 00000000000..4185f363e6c --- /dev/null +++ b/ai_chatgpt_oauth/views/ai_chatgpt_model_views.xml @@ -0,0 +1,77 @@ + + + + + ai.chatgpt.model.list + ai.chatgpt.model + + + + + + + + + + + + + + ai.chatgpt.model.form + ai.chatgpt.model + +
+ +
+
+ + + + + + + + + + + + + + +
+
+
+
+ + + + ai.chatgpt.model.search + ai.chatgpt.model + + + + + + + + + + + + + ChatGPT Subscription Models + ai.chatgpt.model + list,form + +

+ Add a new ChatGPT subscription model! +

+

+ Configure technical model identifiers supported by OpenAI Codex endpoint (e.g. gpt-5.6-luna, gpt-5.5, gpt-5.4, etc.). + These models will immediately become selectable on all AI Agents. +

+
+
+
diff --git a/ai_chatgpt_oauth/views/res_config_settings_views.xml b/ai_chatgpt_oauth/views/res_config_settings_views.xml new file mode 100644 index 00000000000..a12d415167e --- /dev/null +++ b/ai_chatgpt_oauth/views/res_config_settings_views.xml @@ -0,0 +1,153 @@ + + + + res.config.settings.view.form.inherit.ai.chatgpt.oauth + res.config.settings + + + + + + + + + +
+ +
+
+ + +
+
+ + +
+ +
+

+ Sign in directly with your ChatGPT account (Free, Plus, Team, or Pro) without paying for API tokens. +

+
+ + +
+
+ +
+
ChatGPT Subscription Connected
+
AI Agents and Chat route through this subscription
+
+
+ +
+
+ Account: + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+ + Supported Models: When OpenAI launches new models (e.g. gpt-5.7), click Manage Models to add them directly to your AI Agents. +
+
+
+ + +
+
+ + Optional: Add OpenAI API key for Knowledge base & voice + +
+
+ +
+
+ ChatGPT subscription handles text chat & agents. Knowledge embeddings & voice require an API key. +
+
+
+
+
+
+
+
+
+
diff --git a/ai_chatgpt_oauth/wizard/__init__.py b/ai_chatgpt_oauth/wizard/__init__.py new file mode 100644 index 00000000000..bf3b3b8e01b --- /dev/null +++ b/ai_chatgpt_oauth/wizard/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import chatgpt_oauth_wizard diff --git a/ai_chatgpt_oauth/wizard/chatgpt_oauth_wizard.py b/ai_chatgpt_oauth/wizard/chatgpt_oauth_wizard.py new file mode 100644 index 00000000000..2c96ccf7b0a --- /dev/null +++ b/ai_chatgpt_oauth/wizard/chatgpt_oauth_wizard.py @@ -0,0 +1,108 @@ +# Copyright 2026 Mayur Bechara +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from odoo import _, fields, models +from odoo.exceptions import AccessError + + +class ChatGPTOAuthWizard(models.TransientModel): + _name = "ai.chatgpt.oauth.wizard" + _description = "ChatGPT Subscription Connection Wizard" + + state = fields.Selection( + selection=[ + ("authorizing", "Waiting for Authorization"), + ("done", "Connected Successfully"), + ("error", "Error"), + ], + default="authorizing", + string="State", + ) + + user_code = fields.Char(string="One-Time Code", readonly=True) + verification_url = fields.Char( + string="Verification URL", + readonly=True, + default="https://auth.openai.com/codex/device", + ) + device_auth_id = fields.Char(string="Device Auth ID", readonly=True) + interval = fields.Integer(string="Poll Interval", default=5) + account_id = fields.Char(string="ChatGPT Account ID", readonly=True) + status_message = fields.Text(string="Status Message", readonly=True) + + def _ensure_settings_admin(self): + if not self.env.user.has_group("base.group_system"): + raise AccessError(_("Only Settings administrators can connect AI credentials.")) + + def action_verify_and_complete(self): + """Poll OpenAI for user approval and exchange for tokens.""" + self.ensure_one() + self._ensure_settings_admin() + result = self.env["ai.chatgpt.oauth"].poll_and_exchange(self.device_auth_id, self.user_code) + + if result.get("status") == "success": + self.write({ + "state": "done", + "account_id": result.get("account_id", ""), + "status_message": _( + "ChatGPT is connected for AI chat and agents. " + "Knowledge sources and voice features use the optional OpenAI API key." + ), + }) + return { + "name": _("Connect ChatGPT Subscription"), + "type": "ir.actions.act_window", + "res_model": self._name, + "res_id": self.id, + "view_mode": "form", + "target": "new", + } + + elif result.get("status") == "pending": + self.write({ + "status_message": _("Authorization pending: Please open the verification link, enter code '%s', and approve in your browser before clicking Verify.") % self.user_code, + }) + return { + "name": _("Connect ChatGPT Subscription"), + "type": "ir.actions.act_window", + "res_model": self._name, + "res_id": self.id, + "view_mode": "form", + "target": "new", + } + + else: + self.write({ + "state": "error", + "status_message": result.get("message") or _("Authorization failed or expired. Please retry."), + }) + return { + "name": _("Connect ChatGPT Subscription"), + "type": "ir.actions.act_window", + "res_model": self._name, + "res_id": self.id, + "view_mode": "form", + "target": "new", + } + + def action_retry(self): + """Restart the device authorization flow.""" + self.ensure_one() + self._ensure_settings_admin() + auth_data = self.env["ai.chatgpt.oauth"].initiate_device_auth() + self.write({ + "state": "authorizing", + "device_auth_id": auth_data["device_auth_id"], + "user_code": auth_data["user_code"], + "verification_url": auth_data["verification_url"], + "interval": auth_data["interval"], + "status_message": False, + }) + return { + "name": _("Connect ChatGPT Subscription"), + "type": "ir.actions.act_window", + "res_model": self._name, + "res_id": self.id, + "view_mode": "form", + "target": "new", + } diff --git a/ai_chatgpt_oauth/wizard/chatgpt_oauth_wizard_views.xml b/ai_chatgpt_oauth/wizard/chatgpt_oauth_wizard_views.xml new file mode 100644 index 00000000000..168d5f5a57e --- /dev/null +++ b/ai_chatgpt_oauth/wizard/chatgpt_oauth_wizard_views.xml @@ -0,0 +1,69 @@ + + + + ai.chatgpt.oauth.wizard.form + ai.chatgpt.oauth.wizard + +
+ + + + +
+
+ Sign in with OpenAI to use your eligible ChatGPT subscription for Odoo AI chat and agents. +
+ + + + + +

+ Open the verification link, enter the one-time code, approve access, then click Verify & Connect. +

+ +
+ + +
+
+ +
+
+ ChatGPT is connected for Odoo AI chat and agents. Select the model on each AI Agent. Knowledge sources and voice features use the optional OpenAI API key. +
+ + + +
+ +
+ +
+ +
+
+ +
+
+