Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/api/ai_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class AIProviderSettingsRequest(BaseModel):
clearApiKey: bool = False
adminApiKey: str | None = None
clearAdminApiKey: bool = False
providerProjectId: str = ""
providerApiKeyId: str = ""
keyPolicy: str | None = None
assistMode: str = "auto"
defaultModel: str = ""
Expand Down
5 changes: 5 additions & 0 deletions backend/services/ai_cost_reconciliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ class UsageCostEvent:
provider: str
model_name: str
estimated_cost_usd: Decimal
input_tokens: int = 0
cached_input_tokens: int = 0
output_tokens: int = 0
provider_project_id: str = ""
provider_api_key_id: str = ""


@dataclass(frozen=True)
Expand Down
2 changes: 2 additions & 0 deletions backend/services/openai_assist_accounting.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ def _assist_event_base(
"decision_reason": decision_reason,
"paid_by": settings["paidBy"],
"provider_key_owner_user_id": settings.get("providerKeyOwnerUserId"),
"provider_project_id": settings.get("providerProjectId") or "",
"provider_api_key_id": settings.get("providerApiKeyId") or "",
}

def _estimate_openai_cost(self, settings: dict[str, Any], usage: dict[str, int]) -> float:
Expand Down
118 changes: 116 additions & 2 deletions db/ai_provider_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,8 @@ def record_ai_assist_event(
estimated_cost: float = 0.0,
paid_by: str = "unknown",
provider_key_owner_user_id: int | None = None,
provider_project_id: str = "",
provider_api_key_id: str = "",
success: bool = True,
error_message: str | None = None,
decision_reason: str = "",
Expand All @@ -351,6 +353,8 @@ def record_ai_assist_event(
float(estimated_cost or 0),
paid_by,
provider_key_owner_user_id,
str(provider_project_id or "").strip(),
str(provider_api_key_id or "").strip(),
bool(success),
str(error_message or "")[:1000] if error_message else None,
str(decision_reason or "")[:1000],
Expand All @@ -359,20 +363,101 @@ def record_ai_assist_event(
).fetchone()
return int(row["id"]) if row else None

def list_openai_events_for_reconciliation(self, *, start_time: datetime, end_time: datetime) -> list[UsageCostEvent]:
def list_openai_events_for_reconciliation(
self,
*,
start_time: datetime,
end_time: datetime,
project_ids: list[str] | None = None,
api_key_ids: list[str] | None = None,
) -> list[UsageCostEvent]:
project_filter = self._filter_or_none(project_ids)
api_key_filter = self._filter_or_none(api_key_ids)
with self.database.connection() as conn:
rows = conn.execute(load_query("ai_assist_events_for_reconciliation.sql"), (start_time, end_time)).fetchall()
rows = conn.execute(
load_query("ai_assist_events_for_reconciliation.sql"),
(start_time, end_time, project_filter, project_filter, api_key_filter, api_key_filter),
).fetchall()
return [
UsageCostEvent(
event_id=int(row["id"]),
created_at=row["created_at"],
provider=row["provider"],
model_name=row["model_name"] or "",
estimated_cost_usd=Decimal(str(row["estimated_cost"] or "0")),
input_tokens=int(row.get("input_tokens") or 0),
cached_input_tokens=int(row.get("cached_input_tokens") or 0),
output_tokens=int(row.get("output_tokens") or 0),
provider_project_id=row.get("provider_project_id") or "",
provider_api_key_id=row.get("provider_api_key_id") or "",
)
for row in rows
]

def reprice_openai_events_for_reconciliation(
self,
*,
start_time: datetime,
end_time: datetime,
project_ids: list[str] | None = None,
api_key_ids: list[str] | None = None,
) -> int:
project_filter = self._filter_or_none(project_ids)
api_key_filter = self._filter_or_none(api_key_ids)
with self.database.connection() as conn:
rows = conn.execute(
load_query("ai_assist_events_for_reconciliation.sql"),
(start_time, end_time, project_filter, project_filter, api_key_filter, api_key_filter),
).fetchall()
update_query = load_query("ai_assist_event_estimated_cost_update.sql")
updated = 0
for row in rows:
paid_by = row.get("paid_by") or "system"
user_id = row.get("provider_key_owner_user_id") if paid_by == "user" else row.get("user_id")
estimate = self.estimate_cost(
provider="openai",
model_name=row.get("model_name") or "",
input_tokens=int(row.get("input_tokens") or 0),
cached_input_tokens=int(row.get("cached_input_tokens") or 0),
output_tokens=int(row.get("output_tokens") or 0),
billing_scope=paid_by,
entity_id=row.get("entity_id") if paid_by == "entity" else None,
user_id=user_id if paid_by == "user" else None,
)
conn.execute(update_query, (Decimal(str(estimate)), row["id"]))
updated += 1
return updated

def clear_openai_reconciliation_window(
self,
*,
start_time: datetime,
end_time: datetime,
project_ids: list[str] | None = None,
api_key_ids: list[str] | None = None,
) -> None:
project_filter = self._filter_or_none(project_ids)
api_key_filter = self._filter_or_none(api_key_ids)
with self.database.connection() as conn:
conn.execute(
load_query("ai_assist_event_reconciliation_clear.sql"),
(start_time, end_time, project_filter, project_filter, api_key_filter, api_key_filter),
)

def openai_reconciliation_filters(self) -> dict[str, list[str]]:
project_ids: list[str] = []
api_key_ids: list[str] = []
with self.database.connection() as conn:
rows = conn.execute(load_query("openai_reconciliation_configured_filters.sql")).fetchall()
for row in rows:
project_id = str(row.get("provider_project_id") or "").strip()
api_key_id = str(row.get("provider_api_key_id") or "").strip()
if project_id and project_id not in project_ids:
project_ids.append(project_id)
if api_key_id and api_key_id not in api_key_ids:
api_key_ids.append(api_key_id)
return {"projectIds": project_ids, "apiKeyIds": api_key_ids}

def create_cost_reconciliation_run(
self,
*,
Expand Down Expand Up @@ -564,6 +649,8 @@ def save_system_settings(self, payload: dict[str, Any], updated_by: int | None,
key_value,
self.encryption_secret(),
preview,
self._billing_id(payload.get("providerProjectId")),
self._billing_id(payload.get("providerApiKeyId")),
ids["assist_mode_id"],
str(payload.get("defaultModel") or ""),
updated_by,
Expand Down Expand Up @@ -609,6 +696,8 @@ def save_entity_settings(self, entity_id: int, payload: dict[str, Any], updated_
key_value,
self.encryption_secret(),
preview,
self._billing_id(payload.get("providerProjectId")),
self._billing_id(payload.get("providerApiKeyId")),
ids["key_policy_id"],
ids["assist_mode_id"],
str(payload.get("defaultModel") or ""),
Expand Down Expand Up @@ -646,6 +735,8 @@ def save_user_settings(self, user_id: int, payload: dict[str, Any], provider: st
key_value,
self.encryption_secret(),
preview,
self._billing_id(payload.get("providerProjectId")),
self._billing_id(payload.get("providerApiKeyId")),
ids["key_policy_id"],
ids["assist_mode_id"],
str(payload.get("defaultModel") or ""),
Expand Down Expand Up @@ -723,6 +814,8 @@ def _secret_row(
"adminApiKey": row.get("admin_api_key") or "",
"keyPreview": row.get("key_preview") or "",
"adminKeyPreview": row.get("admin_key_preview") or "",
"providerProjectId": row.get("provider_project_id") or "",
"providerApiKeyId": row.get("provider_api_key_id") or "",
"keyPolicy": row.get("key_policy") or ("system" if scope == "system" else scope),
"assistMode": row.get("assist_mode") or "auto",
"defaultModel": row.get("default_model") or "",
Expand All @@ -748,6 +841,8 @@ def _resolved_provider(
"modelName": row.get("defaultModel") or default_model,
"paidBy": paid_by,
"providerKeyOwnerUserId": owner_user_id if paid_by == "user" else None,
"providerProjectId": row.get("providerProjectId") or "",
"providerApiKeyId": row.get("providerApiKeyId") or "",
"pricingScope": paid_by,
"pricingEntityId": entity_id if paid_by == "entity" else None,
"pricingUserId": user_id if paid_by == "user" else None,
Expand Down Expand Up @@ -776,6 +871,8 @@ def _settings_row(self, row: dict[str, Any] | None, *, scope: str, provider: str
"hasAdminApiKey": False,
"keyPreview": "",
"adminKeyPreview": "",
"providerProjectId": "",
"providerApiKeyId": "",
"keyPolicy": "system" if scope == "system" else ("entity" if scope == "entity" else "user_when_available"),
"assistMode": "auto",
"defaultModel": "",
Expand All @@ -795,6 +892,8 @@ def _settings_row(self, row: dict[str, Any] | None, *, scope: str, provider: str
"hasAdminApiKey": bool(row.get("admin_key_preview")),
"keyPreview": row["key_preview"] or "",
"adminKeyPreview": row.get("admin_key_preview") or "",
"providerProjectId": row.get("provider_project_id") or "",
"providerApiKeyId": row.get("provider_api_key_id") or "",
"keyPolicy": row.get("key_policy") or ("system" if scope == "system" else "entity"),
"assistMode": row["assist_mode"],
"defaultModel": row["default_model"] or "",
Expand Down Expand Up @@ -840,6 +939,8 @@ def _usage_event_row(self, row: dict[str, Any]) -> dict[str, Any]:
"paidBy": row.get("paid_by") or "unknown",
"providerKeyOwnerUserId": row.get("provider_key_owner_user_id"),
"providerKeyOwnerUsername": row.get("provider_key_owner_username"),
"providerProjectId": row.get("provider_project_id") or "",
"providerApiKeyId": row.get("provider_api_key_id") or "",
"success": bool(row.get("success")),
"errorMessage": row.get("error_message"),
"decisionReason": row.get("decision_reason") or "",
Expand Down Expand Up @@ -902,3 +1003,16 @@ def _context_label(context_type: Any, context_id: Any) -> str:
if not context_id:
return str(context_type)
return f"{context_type}:{str(context_id)[:8]}"

@staticmethod
def _billing_id(value: Any) -> str:
return str(value or "").strip()[:120]

@staticmethod
def _filter_or_none(values: list[str] | None) -> list[str] | None:
clean: list[str] = []
for value in values or []:
item = str(value or "").strip()
if item and item not in clean:
clean.append(item)
return clean or None
112 changes: 112 additions & 0 deletions db/migrations/071_openai_reconciliation_billing_ids.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
BEGIN;

ALTER TABLE system_ai_provider_settings
ADD COLUMN IF NOT EXISTS provider_project_id text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS provider_api_key_id text NOT NULL DEFAULT '';

ALTER TABLE entity_ai_provider_settings
ADD COLUMN IF NOT EXISTS provider_project_id text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS provider_api_key_id text NOT NULL DEFAULT '';

ALTER TABLE user_ai_provider_settings
ADD COLUMN IF NOT EXISTS provider_project_id text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS provider_api_key_id text NOT NULL DEFAULT '';

ALTER TABLE ai_assist_events
ADD COLUMN IF NOT EXISTS provider_project_id text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS provider_api_key_id text NOT NULL DEFAULT '';

CREATE INDEX IF NOT EXISTS ai_assist_events_provider_billing_ids_idx
ON ai_assist_events (provider_type_id, provider_project_id, provider_api_key_id, created_at DESC);

UPDATE ai_model_pricing pricing
SET input_per_million = 5.00,
cached_input_per_million = 0.50,
output_per_million = 30.00,
updated_at = now()
FROM ai_provider_types provider
WHERE pricing.provider_type_id = provider.id
AND provider.code = 'openai'
AND pricing.model_name = 'gpt-5-chat-latest';

UPDATE ai_assist_events ev
SET provider_project_id = coalesce(nullif(s.provider_project_id, ''), ev.provider_project_id),
provider_api_key_id = coalesce(nullif(s.provider_api_key_id, ''), ev.provider_api_key_id)
FROM system_ai_provider_settings s
JOIN ai_provider_types provider ON provider.id = s.provider_type_id
WHERE ev.provider_type_id = provider.id
AND provider.code = 'openai'
AND ev.paid_by = 'system';

UPDATE ai_assist_events ev
SET provider_project_id = coalesce(nullif(s.provider_project_id, ''), ev.provider_project_id),
provider_api_key_id = coalesce(nullif(s.provider_api_key_id, ''), ev.provider_api_key_id)
FROM entity_ai_provider_settings s
JOIN ai_provider_types provider ON provider.id = s.provider_type_id
WHERE ev.provider_type_id = provider.id
AND provider.code = 'openai'
AND ev.paid_by = 'entity'
AND ev.entity_id = s.entity_id;

UPDATE ai_assist_events ev
SET provider_project_id = coalesce(nullif(s.provider_project_id, ''), ev.provider_project_id),
provider_api_key_id = coalesce(nullif(s.provider_api_key_id, ''), ev.provider_api_key_id)
FROM user_ai_provider_settings s
JOIN ai_provider_types provider ON provider.id = s.provider_type_id
WHERE ev.provider_type_id = provider.id
AND provider.code = 'openai'
AND ev.paid_by = 'user'
AND ev.provider_key_owner_user_id = s.user_id;

DROP VIEW IF EXISTS ai_assist_events_expanded;

CREATE VIEW ai_assist_events_expanded AS
SELECT ev.id,
ev.created_at,
ev.entity_id,
en.name AS entity_name,
ev.user_id,
u.username,
coalesce(nullif(u.display_name, ''), nullif(u.nickname, ''), u.username::text) AS user_label,
ev.provider_key_owner_user_id,
key_owner.username AS provider_key_owner_username,
coalesce(nullif(key_owner.display_name, ''), nullif(key_owner.nickname, ''), key_owner.username::text) AS provider_key_owner_label,
ev.provider_project_id,
ev.provider_api_key_id,
ev.provider_type_id,
provider.code AS provider_code,
provider.display_name AS provider_name,
ev.task_type_id,
task.code AS task_code,
task.display_name AS task_name,
ev.model_name,
ev.context_type,
ev.context_id,
ev.round_number,
ev.round_count,
ev.input_tokens,
ev.cached_input_tokens,
ev.output_tokens,
ev.estimated_cost,
ev.final_cost,
ev.cost_status,
ev.cost_discrepancy,
ev.reconciliation_run_id,
ev.allocation_method,
ev.paid_by,
ev.success,
ev.error_message,
ev.decision_reason,
ev.latency_ms
FROM ai_assist_events ev
LEFT JOIN entities en ON en.id = ev.entity_id
LEFT JOIN users u ON u.id = ev.user_id
LEFT JOIN users key_owner ON key_owner.id = ev.provider_key_owner_user_id
LEFT JOIN ai_provider_types provider ON provider.id = ev.provider_type_id
LEFT JOIN ai_task_types task ON task.id = ev.task_type_id;

INSERT INTO schema_migrations (version, name)
VALUES (71, 'openai_reconciliation_billing_ids')
ON CONFLICT (version) DO NOTHING;

COMMIT;
4 changes: 4 additions & 0 deletions db/queries/ai_assist_event_estimated_cost_update.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
UPDATE ai_assist_events
SET estimated_cost = %s
WHERE id = %s
RETURNING id;
4 changes: 4 additions & 0 deletions db/queries/ai_assist_event_insert.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ INSERT INTO ai_assist_events (
estimated_cost,
paid_by,
provider_key_owner_user_id,
provider_project_id,
provider_api_key_id,
success,
error_message,
decision_reason,
Expand All @@ -38,6 +40,8 @@ VALUES (
%s,
%s,
%s,
%s,
%s,
%s
)
RETURNING id;
14 changes: 14 additions & 0 deletions db/queries/ai_assist_event_reconciliation_clear.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
UPDATE ai_assist_events ev
SET final_cost = NULL,
cost_status = 'estimated',
cost_discrepancy = 0,
reconciliation_run_id = NULL,
allocation_method = ''
FROM ai_provider_types provider
WHERE ev.provider_type_id = provider.id
AND provider.code = 'openai'
AND ev.success = true
AND ev.created_at >= %s
AND ev.created_at < %s
AND (%s::text[] IS NULL OR ev.provider_project_id = '' OR ev.provider_project_id = ANY(%s::text[]))
AND (%s::text[] IS NULL OR ev.provider_api_key_id = '' OR ev.provider_api_key_id = ANY(%s::text[]));
Loading