This commit is contained in:
gsinghpal
2026-03-16 08:14:56 -04:00
parent fdca9518ab
commit e56974d46f
196 changed files with 19739 additions and 3471 deletions

View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import api_provider
from . import api_key
from . import api_consumer
from . import api_access
from . import api_user_limit
from . import api_usage
from . import api_usage_daily
from . import api_service
from . import res_config_settings

View File

@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionApiAccess(models.Model):
_name = 'fusion.api.access'
_description = 'API Access Rule'
_order = 'consumer_id, provider_id'
_rec_name = 'display_name'
consumer_id = fields.Many2one(
'fusion.api.consumer', required=True, ondelete='cascade',
)
provider_id = fields.Many2one(
'fusion.api.provider', required=True, ondelete='cascade',
)
is_enabled = fields.Boolean(default=True, string='Enabled', tracking=True)
monthly_budget_usd = fields.Float(
string='Monthly Budget (USD)',
help='Maximum monthly spend. 0 = unlimited.',
)
daily_budget_usd = fields.Float(
string='Daily Budget (USD)',
help='Maximum daily spend. 0 = unlimited.',
)
max_rpm = fields.Integer(
string='Max Requests/Min',
help='Maximum requests per minute. 0 = unlimited.',
)
max_rpd = fields.Integer(
string='Max Requests/Day',
help='Maximum requests per day. 0 = unlimited.',
)
current_month_cost = fields.Float(
compute='_compute_current_usage', string='Month Spend',
)
current_day_cost = fields.Float(
compute='_compute_current_usage', string='Today Spend',
)
current_day_requests = fields.Integer(
compute='_compute_current_usage', string='Today Requests',
)
budget_usage_pct = fields.Float(
compute='_compute_current_usage', string='Budget Used %',
)
is_budget_exceeded = fields.Boolean(compute='_compute_current_usage')
display_name = fields.Char(compute='_compute_display_name', store=True)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
_sql_constraints = [
('consumer_provider_uniq', 'unique(consumer_id, provider_id, company_id)',
'Only one access rule per consumer-provider pair per company.'),
]
@api.depends('consumer_id.name', 'provider_id.name')
def _compute_display_name(self):
for rec in self:
consumer = rec.consumer_id.name or ''
provider = rec.provider_id.name or ''
rec.display_name = f"{consumer} / {provider}"
def _compute_current_usage(self):
today = fields.Date.today()
month_start = today.replace(day=1)
for rec in self:
month_usages = self.env['fusion.api.usage'].sudo().search([
('consumer_id', '=', rec.consumer_id.id),
('provider_id', '=', rec.provider_id.id),
('create_date', '>=', fields.Datetime.to_string(month_start)),
('status', '=', 'success'),
])
day_usages = month_usages.filtered(
lambda u: u.create_date.date() == today
)
rec.current_month_cost = sum(u.estimated_cost_usd for u in month_usages)
rec.current_day_cost = sum(u.estimated_cost_usd for u in day_usages)
rec.current_day_requests = len(day_usages)
if rec.monthly_budget_usd > 0:
rec.budget_usage_pct = (
rec.current_month_cost / rec.monthly_budget_usd
) * 100
rec.is_budget_exceeded = (
rec.current_month_cost >= rec.monthly_budget_usd
)
else:
rec.budget_usage_pct = 0.0
rec.is_budget_exceeded = False

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionApiConsumer(models.Model):
_name = 'fusion.api.consumer'
_description = 'API Consumer (Fusion Module)'
_order = 'name'
name = fields.Char(required=True, string='Display Name')
technical_name = fields.Char(required=True, index=True)
module_id = fields.Many2one('ir.module.module', string='Odoo Module', readonly=True)
module_state = fields.Selection(
related='module_id.state', string='Module Status', readonly=True,
)
is_active = fields.Boolean(default=True, string='API Access Enabled', tracking=True)
auto_detected = fields.Boolean(default=False, readonly=True)
first_seen_at = fields.Datetime(readonly=True)
access_ids = fields.One2many('fusion.api.access', 'consumer_id', string='Access Rules')
total_month_cost = fields.Float(
compute='_compute_usage_stats', string='Month Cost (USD)',
)
total_month_requests = fields.Integer(
compute='_compute_usage_stats', string='Month Requests',
)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
_sql_constraints = [
('technical_name_company_uniq', 'unique(technical_name, company_id)',
'Consumer technical name must be unique per company.'),
]
def _compute_usage_stats(self):
today = fields.Date.today()
month_start = today.replace(day=1)
for rec in self:
usages = self.env['fusion.api.usage'].sudo().search([
('consumer_id', '=', rec.id),
('create_date', '>=', fields.Datetime.to_string(month_start)),
('status', '=', 'success'),
])
rec.total_month_cost = sum(u.estimated_cost_usd for u in usages)
rec.total_month_requests = len(usages)
def action_toggle_access(self):
for rec in self:
rec.is_active = not rec.is_active

View File

@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class FusionApiKey(models.Model):
_name = 'fusion.api.key'
_description = 'API Key'
_order = 'provider_id, is_default desc, name'
provider_id = fields.Many2one(
'fusion.api.provider', required=True, ondelete='cascade',
)
provider_type = fields.Selection(
related='provider_id.provider_type', store=True, readonly=True,
)
name = fields.Char(required=True, string='Label')
api_key = fields.Char(string='API Key', groups='fusion_api.group_admin')
client_id = fields.Char(groups='fusion_api.group_admin')
client_secret = fields.Char(groups='fusion_api.group_admin')
access_token = fields.Char(groups='fusion_api.group_admin')
refresh_token = fields.Char(groups='fusion_api.group_admin')
token_expiry = fields.Datetime()
redirect_uri = fields.Char()
environment = fields.Selection([
('production', 'Production'),
('sandbox', 'Sandbox'),
], default='production', required=True)
is_active = fields.Boolean(default=True)
is_default = fields.Boolean(default=False)
last_validated_at = fields.Datetime(readonly=True)
validation_status = fields.Selection([
('unknown', 'Not Validated'),
('valid', 'Valid'),
('invalid', 'Invalid'),
], default='unknown', readonly=True)
notes = fields.Text()
masked_key = fields.Char(compute='_compute_masked_key')
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
@api.depends('api_key')
def _compute_masked_key(self):
for rec in self:
key = rec.api_key
if key and len(key) > 8:
rec.masked_key = key[:4] + '*' * (len(key) - 8) + key[-4:]
elif key:
rec.masked_key = '****'
else:
rec.masked_key = ''
@api.constrains('is_default', 'provider_id', 'environment')
def _check_single_default(self):
for rec in self:
if rec.is_default:
duplicates = self.search([
('provider_id', '=', rec.provider_id.id),
('environment', '=', rec.environment),
('is_default', '=', True),
('id', '!=', rec.id),
])
if duplicates:
raise UserError(_(
"Only one default key per provider per environment. "
"Key '%(other)s' is already the default.",
other=duplicates[0].name,
))
def action_validate(self):
self.ensure_one()
try:
self.env['fusion.api.service']._validate_key(self)
self.write({
'last_validated_at': fields.Datetime.now(),
'validation_status': 'valid',
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Validation Successful'),
'message': _('API key is valid and working.'),
'type': 'success',
'sticky': False,
},
}
except UserError as e:
self.write({'validation_status': 'invalid'})
raise
def write(self, vals):
if 'api_key' in vals and not vals['api_key']:
for rec in self:
if rec.api_key:
vals.pop('api_key')
break
return super().write(vals)

View File

@@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionApiProvider(models.Model):
_name = 'fusion.api.provider'
_description = 'API Service Provider'
_order = 'sequence, name'
name = fields.Char(required=True)
provider_type = fields.Selection([
('openai', 'OpenAI'),
('anthropic', 'Anthropic'),
('google_maps', 'Google Maps'),
('google_oauth', 'Google OAuth'),
('microsoft_oauth', 'Microsoft OAuth'),
('twilio', 'Twilio'),
('custom', 'Custom'),
], required=True, default='custom')
status = fields.Selection([
('active', 'Active'),
('inactive', 'Inactive'),
('error', 'Error'),
], default='inactive', required=True, tracking=True)
description = fields.Text()
website_url = fields.Char(string='API Dashboard URL')
sequence = fields.Integer(default=10)
color = fields.Integer()
icon_class = fields.Char(
string='Icon CSS Class',
help='Font Awesome class for display, e.g. fa-brain',
)
key_ids = fields.One2many('fusion.api.key', 'provider_id', string='API Keys')
access_ids = fields.One2many('fusion.api.access', 'provider_id', string='Access Rules')
active_key_count = fields.Integer(compute='_compute_key_stats', string='Active Keys')
total_month_cost = fields.Float(compute='_compute_usage_stats', string='Month Cost (USD)')
total_month_requests = fields.Integer(compute='_compute_usage_stats', string='Month Requests')
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
@api.depends('key_ids', 'key_ids.is_active')
def _compute_key_stats(self):
for rec in self:
rec.active_key_count = len(rec.key_ids.filtered('is_active'))
def _compute_usage_stats(self):
today = fields.Date.today()
month_start = today.replace(day=1)
for rec in self:
usages = self.env['fusion.api.usage'].sudo().search([
('provider_id', '=', rec.id),
('create_date', '>=', fields.Datetime.to_string(month_start)),
('status', '=', 'success'),
])
rec.total_month_cost = sum(u.estimated_cost_usd for u in usages)
rec.total_month_requests = len(usages)
def action_activate(self):
self.ensure_one()
if not self.key_ids.filtered('is_active'):
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No Active Keys',
'message': 'Add at least one active API key before activating this provider.',
'type': 'warning',
'sticky': False,
},
}
self.status = 'active'
def action_deactivate(self):
self.ensure_one()
self.status = 'inactive'
@api.model
def get_dashboard_data(self):
today = fields.Date.today()
month_start = today.replace(day=1)
providers = self.search([])
consumers = self.env['fusion.api.consumer'].search([])
month_usages = self.env['fusion.api.usage'].sudo().search([
('create_date', '>=', fields.Datetime.to_string(month_start)),
('status', '=', 'success'),
])
today_usages = month_usages.filtered(
lambda u: u.create_date.date() == today
)
month_cost = sum(u.estimated_cost_usd for u in month_usages)
month_requests = len(month_usages)
today_requests = len(today_usages)
consumer_stats = {}
for usage in month_usages:
cid = usage.consumer_id.id
if cid not in consumer_stats:
consumer_stats[cid] = {
'name': usage.consumer_id.name,
'cost': 0.0,
'requests': 0,
}
consumer_stats[cid]['cost'] += usage.estimated_cost_usd
consumer_stats[cid]['requests'] += 1
top_consumers = sorted(
consumer_stats.values(),
key=lambda x: x['cost'],
reverse=True,
)[:5]
provider_stats = []
for prov in providers.filtered(lambda p: p.status == 'active'):
prov_usages = month_usages.filtered(lambda u: u.provider_id.id == prov.id)
provider_stats.append({
'id': prov.id,
'name': prov.name,
'type': prov.provider_type,
'cost': sum(u.estimated_cost_usd for u in prov_usages),
'requests': len(prov_usages),
'keys': prov.active_key_count,
})
recent = self.env['fusion.api.usage'].sudo().search(
[], limit=10, order='create_date desc',
)
recent_list = [{
'consumer': r.consumer_id.name or '',
'provider': r.provider_id.name or '',
'feature': r.feature or '',
'cost': round(r.estimated_cost_usd, 6),
'status': r.status,
'tokens': r.total_tokens,
'time': fields.Datetime.to_string(r.create_date),
} for r in recent]
approaching_limits = []
access_rules = self.env['fusion.api.access'].sudo().search([
('monthly_budget_usd', '>', 0),
('is_enabled', '=', True),
])
for rule in access_rules:
pct = rule.budget_usage_pct
if pct >= 80:
approaching_limits.append({
'consumer': rule.consumer_id.name,
'provider': rule.provider_id.name,
'pct': round(pct, 1),
'budget': rule.monthly_budget_usd,
'spent': round(rule.current_month_cost, 2),
})
return {
'total_providers': len(providers),
'active_providers': len(providers.filtered(lambda p: p.status == 'active')),
'total_consumers': len(consumers),
'active_consumers': len(consumers.filtered('is_active')),
'month_cost': round(month_cost, 2),
'month_requests': month_requests,
'today_requests': today_requests,
'top_consumers': top_consumers,
'provider_stats': provider_stats,
'recent_usage': recent_list,
'approaching_limits': approaching_limits,
}

View File

@@ -0,0 +1,499 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import time
import logging
from datetime import timedelta
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
try:
from openai import OpenAI
except ImportError:
OpenAI = None
_logger.info("openai package not installed. OpenAI calls via Fusion API unavailable.")
try:
import anthropic as anthropic_sdk
except ImportError:
anthropic_sdk = None
_logger.info("anthropic package not installed. Anthropic calls via Fusion API unavailable.")
class FusionApiService(models.AbstractModel):
_name = 'fusion.api.service'
_description = 'Fusion API Service'
COST_PER_1K = {
'openai': {
'gpt-4o': {'input': 0.0025, 'output': 0.01},
'gpt-4o-mini': {'input': 0.00015, 'output': 0.0006},
'gpt-4-turbo': {'input': 0.01, 'output': 0.03},
'gpt-3.5-turbo': {'input': 0.0005, 'output': 0.0015},
'o1': {'input': 0.015, 'output': 0.06},
'o1-mini': {'input': 0.003, 'output': 0.012},
},
'anthropic': {
'claude-sonnet-4-20250514': {'input': 0.003, 'output': 0.015},
'claude-3-5-haiku-20241022': {'input': 0.001, 'output': 0.005},
'claude-3-opus-20240229': {'input': 0.015, 'output': 0.075},
},
}
# ------------------------------------------------------------------
# Provider / key resolution
# ------------------------------------------------------------------
def _get_provider(self, provider_type):
provider = self.env['fusion.api.provider'].sudo().search([
('provider_type', '=', provider_type),
('status', '=', 'active'),
('company_id', '=', self.env.company.id),
], limit=1)
if not provider:
provider = self.env['fusion.api.provider'].sudo().search([
('provider_type', '=', provider_type),
('status', '=', 'active'),
], limit=1)
if not provider:
raise UserError(_(
"No active %(type)s provider configured. "
"Go to Fusion API > Providers to set one up.",
type=provider_type,
))
return provider
def _get_default_key(self, provider):
domain = [
('provider_id', '=', provider.id),
('is_active', '=', True),
('environment', '=', 'production'),
]
key = self.env['fusion.api.key'].sudo().search(
domain + [('is_default', '=', True)], limit=1,
)
if not key:
key = self.env['fusion.api.key'].sudo().search(domain, limit=1)
if not key:
raise UserError(_(
"No active API key for provider '%(provider)s'. "
"Go to Fusion API > Providers to add one.",
provider=provider.name,
))
return key
# ------------------------------------------------------------------
# Consumer auto-registration
# ------------------------------------------------------------------
def _auto_register_consumer(self, consumer_name):
consumer = self.env['fusion.api.consumer'].sudo().search([
('technical_name', '=', consumer_name),
], limit=1)
if consumer:
return consumer
ICP = self.env['ir.config_parameter'].sudo()
if not ICP.get_param('fusion_api.auto_detect_consumers', 'True') == 'True':
raise UserError(_(
"Consumer '%(name)s' is not registered and auto-detection is disabled.",
name=consumer_name,
))
module = self.env['ir.module.module'].sudo().search([
('name', '=', consumer_name),
], limit=1)
display = consumer_name.replace('fusion_', 'Fusion ').replace('_', ' ').title()
consumer = self.env['fusion.api.consumer'].sudo().create({
'name': display,
'technical_name': consumer_name,
'module_id': module.id if module else False,
'is_active': True,
'auto_detected': True,
'first_seen_at': fields.Datetime.now(),
})
_logger.info("Auto-registered API consumer: %s", consumer_name)
return consumer
# ------------------------------------------------------------------
# Access control checks
# ------------------------------------------------------------------
def _check_access(self, consumer, provider, user=None):
if not consumer.is_active:
raise UserError(_(
"API access is disabled for '%(module)s'. "
"An administrator can re-enable it in Fusion API > Consumers.",
module=consumer.name,
))
access = self.env['fusion.api.access'].sudo().search([
('consumer_id', '=', consumer.id),
('provider_id', '=', provider.id),
], limit=1)
if access and not access.is_enabled:
raise UserError(_(
"'%(module)s' access to '%(provider)s' is disabled.",
module=consumer.name, provider=provider.name,
))
if access:
self._check_budget(access, consumer, provider)
self._check_rate_limit(access, consumer, provider)
if user:
self._check_user_limit(user, provider)
return access
def _check_budget(self, access, consumer, provider):
if access.monthly_budget_usd > 0 and access.is_budget_exceeded:
raise UserError(_(
"Monthly budget of $%(budget).2f exceeded for "
"'%(module)s' on '%(provider)s' ($%(spent).2f spent).",
budget=access.monthly_budget_usd,
module=consumer.name,
provider=provider.name,
spent=access.current_month_cost,
))
if access.daily_budget_usd > 0:
if access.current_day_cost >= access.daily_budget_usd:
raise UserError(_(
"Daily budget of $%(budget).2f exceeded for "
"'%(module)s' on '%(provider)s'.",
budget=access.daily_budget_usd,
module=consumer.name,
provider=provider.name,
))
def _check_rate_limit(self, access, consumer, provider):
if access.max_rpm > 0:
one_min_ago = fields.Datetime.now() - timedelta(minutes=1)
recent_count = self.env['fusion.api.usage'].sudo().search_count([
('consumer_id', '=', consumer.id),
('provider_id', '=', provider.id),
('create_date', '>=', one_min_ago),
])
if recent_count >= access.max_rpm:
raise UserError(_(
"Rate limit of %(limit)d requests/minute exceeded for "
"'%(module)s' on '%(provider)s'.",
limit=access.max_rpm,
module=consumer.name,
provider=provider.name,
))
if access.max_rpd > 0:
today = fields.Date.today()
day_count = self.env['fusion.api.usage'].sudo().search_count([
('consumer_id', '=', consumer.id),
('provider_id', '=', provider.id),
('create_date', '>=', fields.Datetime.to_string(today)),
])
if day_count >= access.max_rpd:
raise UserError(_(
"Daily limit of %(limit)d requests exceeded for "
"'%(module)s' on '%(provider)s'.",
limit=access.max_rpd,
module=consumer.name,
provider=provider.name,
))
def _check_user_limit(self, user, provider):
limit = self.env['fusion.api.user.limit'].sudo().search([
('user_id', '=', user.id),
('provider_id', '=', provider.id),
], limit=1)
if not limit:
return
if limit.is_blocked:
raise UserError(_(
"API access to '%(provider)s' is blocked for user '%(user)s'.",
provider=provider.name, user=user.name,
))
if limit.monthly_budget_usd > 0:
if limit.current_month_cost >= limit.monthly_budget_usd:
raise UserError(_(
"User monthly budget of $%(budget).2f exceeded for "
"'%(provider)s' ($%(spent).2f spent).",
budget=limit.monthly_budget_usd,
provider=provider.name,
spent=limit.current_month_cost,
))
if limit.max_rpd > 0:
if limit.current_day_requests >= limit.max_rpd:
raise UserError(_(
"User daily limit of %(limit)d requests exceeded for "
"'%(provider)s'.",
limit=limit.max_rpd,
provider=provider.name,
))
# ------------------------------------------------------------------
# Usage logging
# ------------------------------------------------------------------
def _log_usage(self, consumer, provider, user, feature, model_name,
tokens_in, tokens_out, cost, response_time_ms,
status, error_message=None):
self.env['fusion.api.usage'].sudo().create({
'consumer_id': consumer.id,
'provider_id': provider.id,
'user_id': user.id if user else False,
'feature': feature,
'model_name': model_name,
'tokens_in': tokens_in,
'tokens_out': tokens_out,
'estimated_cost_usd': cost,
'response_time_ms': response_time_ms,
'status': status,
'error_message': error_message,
})
def _estimate_cost(self, provider_type, model_name, tokens_in, tokens_out):
provider_costs = self.COST_PER_1K.get(provider_type, {})
model_costs = provider_costs.get(
model_name, {'input': 0.001, 'output': 0.002},
)
return (
(tokens_in / 1000) * model_costs['input']
+ (tokens_out / 1000) * model_costs['output']
)
# ------------------------------------------------------------------
# Public API: OpenAI
# ------------------------------------------------------------------
def call_openai(self, consumer, feature, messages,
model=None, max_tokens=1024, user=None, **kwargs):
if OpenAI is None:
raise UserError(_(
"The 'openai' Python package is not installed. "
"Run: pip install openai"
))
provider = self._get_provider('openai')
consumer_rec = self._auto_register_consumer(consumer)
user_rec = user or self.env.user
self._check_access(consumer_rec, provider, user_rec)
key = self._get_default_key(provider)
model_name = model or 'gpt-4o-mini'
start = time.time()
try:
client = OpenAI(api_key=key.api_key)
response = client.chat.completions.create(
model=model_name,
messages=messages,
max_tokens=max_tokens,
**kwargs,
)
elapsed_ms = int((time.time() - start) * 1000)
usage = response.usage
cost = self._estimate_cost(
'openai', model_name,
usage.prompt_tokens, usage.completion_tokens,
)
self._log_usage(
consumer_rec, provider, user_rec, feature, model_name,
usage.prompt_tokens, usage.completion_tokens,
cost, elapsed_ms, 'success',
)
return response.choices[0].message.content
except UserError:
raise
except Exception as e:
elapsed_ms = int((time.time() - start) * 1000)
self._log_usage(
consumer_rec, provider, user_rec, feature, model_name,
0, 0, 0, elapsed_ms, 'error', str(e),
)
_logger.error("OpenAI API error for %s: %s", consumer, e)
raise UserError(_("OpenAI API error: %s", str(e)))
# ------------------------------------------------------------------
# Public API: Anthropic
# ------------------------------------------------------------------
def call_anthropic(self, consumer, feature, messages,
model=None, max_tokens=1024, system=None,
user=None, **kwargs):
if anthropic_sdk is None:
raise UserError(_(
"The 'anthropic' Python package is not installed. "
"Run: pip install anthropic"
))
provider = self._get_provider('anthropic')
consumer_rec = self._auto_register_consumer(consumer)
user_rec = user or self.env.user
self._check_access(consumer_rec, provider, user_rec)
key = self._get_default_key(provider)
model_name = model or 'claude-sonnet-4-20250514'
call_kwargs = {
'model': model_name,
'messages': messages,
'max_tokens': max_tokens,
}
if system:
call_kwargs['system'] = system
call_kwargs.update(kwargs)
start = time.time()
try:
client = anthropic_sdk.Anthropic(api_key=key.api_key)
response = client.messages.create(**call_kwargs)
elapsed_ms = int((time.time() - start) * 1000)
cost = self._estimate_cost(
'anthropic', model_name,
response.usage.input_tokens, response.usage.output_tokens,
)
self._log_usage(
consumer_rec, provider, user_rec, feature, model_name,
response.usage.input_tokens, response.usage.output_tokens,
cost, elapsed_ms, 'success',
)
text_blocks = [
b.text for b in response.content if b.type == 'text'
]
return '\n'.join(text_blocks)
except UserError:
raise
except Exception as e:
elapsed_ms = int((time.time() - start) * 1000)
self._log_usage(
consumer_rec, provider, user_rec, feature, model_name,
0, 0, 0, elapsed_ms, 'error', str(e),
)
_logger.error("Anthropic API error for %s: %s", consumer, e)
raise UserError(_("Anthropic API error: %s", str(e)))
# ------------------------------------------------------------------
# Public API: Raw key access (Google Maps, Twilio, etc.)
# ------------------------------------------------------------------
def get_api_key(self, provider_type, consumer,
feature=None, user=None):
provider = self._get_provider(provider_type)
consumer_rec = self._auto_register_consumer(consumer)
user_rec = user or self.env.user
self._check_access(consumer_rec, provider, user_rec)
key = self._get_default_key(provider)
self._log_usage(
consumer_rec, provider, user_rec,
feature or 'key_access', '', 0, 0, 0, 0, 'success',
)
return key.api_key
def get_oauth_credentials(self, provider_type, consumer,
feature=None, user=None):
provider = self._get_provider(provider_type)
consumer_rec = self._auto_register_consumer(consumer)
user_rec = user or self.env.user
self._check_access(consumer_rec, provider, user_rec)
key = self._get_default_key(provider)
self._log_usage(
consumer_rec, provider, user_rec,
feature or 'oauth_access', '', 0, 0, 0, 0, 'success',
)
return {
'client_id': key.client_id,
'client_secret': key.client_secret,
'access_token': key.access_token,
'refresh_token': key.refresh_token,
'token_expiry': key.token_expiry,
'redirect_uri': key.redirect_uri,
}
# ------------------------------------------------------------------
# Key validation
# ------------------------------------------------------------------
def _validate_key(self, key_record):
ptype = key_record.provider_id.provider_type
if ptype == 'openai':
if OpenAI is None:
raise UserError(_("openai package not installed."))
try:
from openai import AuthenticationError as OpenAIAuthError
try:
client = OpenAI(api_key=key_record.api_key)
client.models.list()
except OpenAIAuthError as e:
raise UserError(_(
"OpenAI key is invalid (authentication failed): %s", str(e),
))
except Exception as e:
err_str = str(e).lower()
if 'billing' in err_str or 'quota' in err_str or 'insufficient' in err_str:
return
raise UserError(_(
"OpenAI key validation failed: %s", str(e),
))
except UserError:
raise
elif ptype == 'anthropic':
if anthropic_sdk is None:
raise UserError(_("anthropic package not installed."))
try:
client = anthropic_sdk.Anthropic(api_key=key_record.api_key)
client.messages.create(
model='claude-3-5-haiku-20241022',
max_tokens=1,
messages=[{'role': 'user', 'content': 'hi'}],
)
except anthropic_sdk.AuthenticationError as e:
raise UserError(_(
"Anthropic key is invalid (authentication failed): %s", str(e),
))
except anthropic_sdk.BadRequestError as e:
if 'credit balance' in str(e) or 'billing' in str(e).lower():
return
raise UserError(_(
"Anthropic key validation failed: %s", str(e),
))
except anthropic_sdk.PermissionDeniedError:
raise UserError(_(
"Anthropic key lacks required permissions.",
))
except Exception as e:
raise UserError(_(
"Anthropic key validation failed: %s", str(e),
))
elif ptype in ('google_maps', 'twilio'):
if not key_record.api_key:
raise UserError(_("API key is empty."))
elif ptype in ('google_oauth', 'microsoft_oauth'):
if not key_record.client_id or not key_record.client_secret:
raise UserError(_("Client ID and Client Secret are required."))
else:
if not key_record.api_key:
raise UserError(_("API key is empty."))

View File

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionApiUsage(models.Model):
_name = 'fusion.api.usage'
_description = 'API Usage Log'
_order = 'create_date desc'
_rec_name = 'display_name'
consumer_id = fields.Many2one(
'fusion.api.consumer', required=True, index=True, ondelete='cascade',
)
provider_id = fields.Many2one(
'fusion.api.provider', required=True, index=True, ondelete='cascade',
)
user_id = fields.Many2one('res.users', index=True, ondelete='set null')
feature = fields.Char(index=True, help='Feature label, e.g. invoice_extraction')
model_name = fields.Char(string='AI Model', help='e.g. gpt-4o, claude-3-5-sonnet')
tokens_in = fields.Integer(string='Input Tokens')
tokens_out = fields.Integer(string='Output Tokens')
total_tokens = fields.Integer(
compute='_compute_total_tokens', store=True, string='Total Tokens',
)
estimated_cost_usd = fields.Float(digits=(10, 6), string='Cost (USD)')
response_time_ms = fields.Integer(string='Response Time (ms)')
status = fields.Selection([
('success', 'Success'),
('error', 'Error'),
('rate_limited', 'Rate Limited'),
('budget_exceeded', 'Budget Exceeded'),
], default='success', required=True, index=True)
error_message = fields.Text()
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
display_name = fields.Char(compute='_compute_display_name')
@api.depends('tokens_in', 'tokens_out')
def _compute_total_tokens(self):
for rec in self:
rec.total_tokens = rec.tokens_in + rec.tokens_out
def _compute_display_name(self):
for rec in self:
consumer = rec.consumer_id.name or 'Unknown'
provider = rec.provider_id.name or 'Unknown'
rec.display_name = f"{consumer} - {provider} ({rec.status})"

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from datetime import timedelta
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionApiUsageDaily(models.Model):
_name = 'fusion.api.usage.daily'
_description = 'API Usage Daily Summary'
_order = 'date desc'
_rec_name = 'display_name'
date = fields.Date(required=True, index=True)
consumer_id = fields.Many2one(
'fusion.api.consumer', required=True, index=True, ondelete='cascade',
)
provider_id = fields.Many2one(
'fusion.api.provider', required=True, index=True, ondelete='cascade',
)
user_id = fields.Many2one('res.users', index=True, ondelete='set null')
feature = fields.Char(index=True)
model_name = fields.Char(string='AI Model')
total_tokens_in = fields.Integer()
total_tokens_out = fields.Integer()
total_tokens = fields.Integer(
compute='_compute_total_tokens', store=True,
)
total_cost_usd = fields.Float(digits=(10, 4))
request_count = fields.Integer()
error_count = fields.Integer()
avg_response_time_ms = fields.Integer()
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
display_name = fields.Char(compute='_compute_display_name')
@api.depends('total_tokens_in', 'total_tokens_out')
def _compute_total_tokens(self):
for rec in self:
rec.total_tokens = rec.total_tokens_in + rec.total_tokens_out
def _compute_display_name(self):
for rec in self:
rec.display_name = (
f"{rec.date} - {rec.consumer_id.name or ''} / "
f"{rec.provider_id.name or ''}"
)
@api.model
def _cron_aggregate_daily(self):
yesterday = fields.Date.today() - timedelta(days=1)
_logger.info("Aggregating API usage for %s", yesterday)
usages = self.env['fusion.api.usage'].sudo().search([
('create_date', '>=', fields.Datetime.to_string(yesterday)),
('create_date', '<', fields.Datetime.to_string(
yesterday + timedelta(days=1)
)),
])
if not usages:
_logger.info("No usage records to aggregate for %s", yesterday)
return
groups = {}
for usage in usages:
key = (
usage.consumer_id.id,
usage.provider_id.id,
usage.user_id.id or 0,
usage.feature or '',
usage.model_name or '',
)
if key not in groups:
groups[key] = {
'date': yesterday,
'consumer_id': usage.consumer_id.id,
'provider_id': usage.provider_id.id,
'user_id': usage.user_id.id or False,
'feature': usage.feature or False,
'model_name': usage.model_name or False,
'total_tokens_in': 0,
'total_tokens_out': 0,
'total_cost_usd': 0.0,
'request_count': 0,
'error_count': 0,
'total_response_time': 0,
}
grp = groups[key]
grp['total_tokens_in'] += usage.tokens_in
grp['total_tokens_out'] += usage.tokens_out
grp['total_cost_usd'] += usage.estimated_cost_usd
grp['request_count'] += 1
grp['total_response_time'] += usage.response_time_ms
if usage.status == 'error':
grp['error_count'] += 1
for grp in groups.values():
total_rt = grp.pop('total_response_time')
count = grp['request_count']
grp['avg_response_time_ms'] = total_rt // count if count else 0
existing = self.sudo().search([
('date', '=', grp['date']),
('consumer_id', '=', grp['consumer_id']),
('provider_id', '=', grp['provider_id']),
('user_id', '=', grp['user_id']),
('feature', '=', grp['feature']),
('model_name', '=', grp['model_name']),
], limit=1)
if existing:
existing.write(grp)
else:
self.sudo().create(grp)
_logger.info(
"Aggregated %d usage records into %d daily summaries",
len(usages), len(groups),
)
@api.model
def _cron_cleanup_old_logs(self):
ICP = self.env['ir.config_parameter'].sudo()
retention_days = int(ICP.get_param('fusion_api.log_retention_days', '90'))
if retention_days <= 0:
return
cutoff = fields.Date.today() - timedelta(days=retention_days)
old_logs = self.env['fusion.api.usage'].sudo().search([
('create_date', '<', fields.Datetime.to_string(cutoff)),
])
count = len(old_logs)
if count:
old_logs.unlink()
_logger.info("Cleaned up %d usage logs older than %s", count, cutoff)

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionApiUserLimit(models.Model):
_name = 'fusion.api.user.limit'
_description = 'API User Limit'
_order = 'user_id, provider_id'
_rec_name = 'display_name'
user_id = fields.Many2one('res.users', required=True, ondelete='cascade')
provider_id = fields.Many2one(
'fusion.api.provider', required=True, ondelete='cascade',
)
monthly_budget_usd = fields.Float(
string='Monthly Budget (USD)',
help='Maximum monthly spend for this user. 0 = unlimited.',
)
max_rpd = fields.Integer(
string='Max Requests/Day',
help='Maximum requests per day. 0 = unlimited.',
)
is_blocked = fields.Boolean(string='Blocked', tracking=True)
current_month_cost = fields.Float(
compute='_compute_current_usage', string='Month Spend',
)
current_day_requests = fields.Integer(
compute='_compute_current_usage', string='Today Requests',
)
display_name = fields.Char(compute='_compute_display_name', store=True)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
_sql_constraints = [
('user_provider_uniq', 'unique(user_id, provider_id, company_id)',
'Only one limit per user-provider pair per company.'),
]
@api.depends('user_id.name', 'provider_id.name')
def _compute_display_name(self):
for rec in self:
user = rec.user_id.name or ''
provider = rec.provider_id.name or ''
rec.display_name = f"{user} - {provider}"
def _compute_current_usage(self):
today = fields.Date.today()
month_start = today.replace(day=1)
for rec in self:
month_usages = self.env['fusion.api.usage'].sudo().search([
('user_id', '=', rec.user_id.id),
('provider_id', '=', rec.provider_id.id),
('create_date', '>=', fields.Datetime.to_string(month_start)),
('status', '=', 'success'),
])
day_usages = month_usages.filtered(
lambda u: u.create_date.date() == today
)
rec.current_month_cost = sum(u.estimated_cost_usd for u in month_usages)
rec.current_day_requests = len(day_usages)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fusion_api_global_budget = fields.Float(
string='Global Monthly Budget (USD)',
config_parameter='fusion_api.global_monthly_budget_usd',
default=0.0,
help="Global monthly budget across all providers. 0 = unlimited.",
)
fusion_api_default_environment = fields.Selection([
('production', 'Production'),
('sandbox', 'Sandbox'),
], string='Default Environment',
config_parameter='fusion_api.default_environment',
default='production',
)
fusion_api_log_retention_days = fields.Integer(
string='Usage Log Retention (days)',
config_parameter='fusion_api.log_retention_days',
default=90,
help="Keep detailed usage logs for this many days. "
"Daily summaries are kept indefinitely. 0 = keep forever.",
)
fusion_api_auto_detect = fields.Boolean(
string='Auto-Detect Consumers',
config_parameter='fusion_api.auto_detect_consumers',
default=True,
help="Automatically register new Fusion modules when they first call the API.",
)