void;
+}) {
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/hooks/useAIProviderSettingsForm.ts b/frontend/src/hooks/useAIProviderSettingsForm.ts
index 0b6de44..d486105 100644
--- a/frontend/src/hooks/useAIProviderSettingsForm.ts
+++ b/frontend/src/hooks/useAIProviderSettingsForm.ts
@@ -104,6 +104,8 @@ export function useAIProviderSettingsForm({
clearApiKey,
adminApiKey: adminApiKey.trim() || undefined,
clearAdminApiKey,
+ providerProjectId: settings.providerProjectId.trim(),
+ providerApiKeyId: settings.providerApiKeyId.trim(),
keyPolicy: settings.keyPolicy,
assistMode: settings.assistMode,
defaultModel: settings.defaultModel,
diff --git a/frontend/src/libs/aiProvider/defaultSettings.ts b/frontend/src/libs/aiProvider/defaultSettings.ts
index d89d58d..41f721c 100644
--- a/frontend/src/libs/aiProvider/defaultSettings.ts
+++ b/frontend/src/libs/aiProvider/defaultSettings.ts
@@ -8,6 +8,8 @@ export const defaultAIProviderSettings: AIProviderSettings = {
hasAdminApiKey: false,
keyPreview: "",
adminKeyPreview: "",
+ providerProjectId: "",
+ providerApiKeyId: "",
keyPolicy: "user_when_available",
assistMode: "auto",
defaultModel: "",
diff --git a/frontend/src/types/ai.ts b/frontend/src/types/ai.ts
index 4f2224c..7a938b1 100644
--- a/frontend/src/types/ai.ts
+++ b/frontend/src/types/ai.ts
@@ -36,6 +36,8 @@ export interface AIProviderSettings {
hasAdminApiKey: boolean;
keyPreview: string;
adminKeyPreview: string;
+ providerProjectId: string;
+ providerApiKeyId: string;
keyPolicy: string;
assistMode: string;
defaultModel: string;
@@ -52,6 +54,8 @@ export interface AIProviderSettingsPayload {
clearApiKey?: boolean;
adminApiKey?: string;
clearAdminApiKey?: boolean;
+ providerProjectId?: string;
+ providerApiKeyId?: string;
keyPolicy?: string;
assistMode: string;
defaultModel: string;
@@ -96,6 +100,8 @@ export interface AIUsageEvent {
paidBy: string;
providerKeyOwnerUserId?: number | null;
providerKeyOwnerUsername?: string | null;
+ providerProjectId?: string;
+ providerApiKeyId?: string;
decisionReason?: string;
latencyMs?: number;
success: boolean;
diff --git a/tests/test_ai_cost_reconciliation.py b/tests/test_ai_cost_reconciliation.py
index 169fbd1..464bc77 100644
--- a/tests/test_ai_cost_reconciliation.py
+++ b/tests/test_ai_cost_reconciliation.py
@@ -132,6 +132,8 @@ def test_usage_event_row_exposes_reconciliation_fields():
"paid_by": "system",
"provider_key_owner_user_id": None,
"provider_key_owner_username": None,
+ "provider_project_id": "proj_123",
+ "provider_api_key_id": "key_456",
"success": True,
"error_message": None,
"decision_reason": "test",
@@ -146,6 +148,8 @@ def test_usage_event_row_exposes_reconciliation_fields():
assert event["costStatus"] == "adjusted"
assert event["costDiscrepancy"] == 0.0025
assert event["reconciliationRunId"] == "11111111-1111-1111-1111-111111111111"
+ assert event["providerProjectId"] == "proj_123"
+ assert event["providerApiKeyId"] == "key_456"
def test_usage_summary_row_exposes_estimated_actual_and_verified_costs():
diff --git a/tests/test_ai_provider_store.py b/tests/test_ai_provider_store.py
index 708df94..1fe6b4e 100644
--- a/tests/test_ai_provider_store.py
+++ b/tests/test_ai_provider_store.py
@@ -91,6 +91,8 @@ def test_ai_provider_settings_row_exposes_admin_key_preview():
"enabled": True,
"key_preview": "sk-live...1234",
"admin_key_preview": "sk-admin...5678",
+ "provider_project_id": "proj_123",
+ "provider_api_key_id": "key_456",
"key_policy": "system",
"assist_mode": "auto",
"default_model": "gpt-5-chat-latest",
@@ -108,9 +110,36 @@ def test_ai_provider_settings_row_exposes_admin_key_preview():
assert settings["hasAdminApiKey"] is True
assert settings["adminKeyPreview"] == "sk-admin...5678"
+ assert settings["providerProjectId"] == "proj_123"
+ assert settings["providerApiKeyId"] == "key_456"
assert "adminApiKey" not in settings
+def test_resolved_provider_carries_billing_ids():
+ settings = AIProviderStore(database=None, config_path="config/config.yaml")._resolved_provider(
+ row={
+ "provider": "openai",
+ "apiKey": "sk-live",
+ "assistMode": "auto",
+ "defaultModel": "gpt-5-chat-latest",
+ "providerProjectId": "proj_123",
+ "providerApiKeyId": "key_456",
+ "monthlyBudget": 0,
+ "warnPercent": 80,
+ "stopPercent": 100,
+ "scope": "system",
+ },
+ paid_by="system",
+ owner_user_id=None,
+ default_model="fallback",
+ entity_id=None,
+ user_id=None,
+ )
+
+ assert settings["providerProjectId"] == "proj_123"
+ assert settings["providerApiKeyId"] == "key_456"
+
+
def test_ai_provider_admin_api_key_for_provider_uses_system_secret_row(monkeypatch):
store = AIProviderStore(database=None, config_path="config/config.yaml")
monkeypatch.setattr(
diff --git a/tools/ai_key_ops.py b/tools/ai_key_ops.py
index 61fd444..f5d8dc1 100644
--- a/tools/ai_key_ops.py
+++ b/tools/ai_key_ops.py
@@ -64,6 +64,8 @@ def restore_keys(database_url: str, input_path: Path) -> int:
row.get("encrypted_admin_api_key") or "",
row.get("key_preview") or "",
row.get("admin_key_preview") or "",
+ row.get("provider_project_id") or "",
+ row.get("provider_api_key_id") or "",
row.get("default_model") or "",
row.get("updated_by"),
row.get("assist_mode") or "auto",
@@ -78,6 +80,8 @@ def restore_keys(database_url: str, input_path: Path) -> int:
bool(row.get("enabled")),
row.get("encrypted_api_key") or "",
row.get("key_preview") or "",
+ row.get("provider_project_id") or "",
+ row.get("provider_api_key_id") or "",
row.get("default_model") or "",
float(row.get("monthly_budget") or 0),
int(row.get("warn_percent") or 80),
@@ -96,6 +100,8 @@ def restore_keys(database_url: str, input_path: Path) -> int:
bool(row.get("enabled")),
row.get("encrypted_api_key") or "",
row.get("key_preview") or "",
+ row.get("provider_project_id") or "",
+ row.get("provider_api_key_id") or "",
row.get("default_model") or "",
float(row.get("monthly_budget") or 0),
int(row.get("warn_percent") or 80),
diff --git a/tools/reconcile_openai_costs.py b/tools/reconcile_openai_costs.py
index ddbf481..8e9bd90 100755
--- a/tools/reconcile_openai_costs.py
+++ b/tools/reconcile_openai_costs.py
@@ -62,6 +62,8 @@ def main() -> int:
parser.add_argument("--group-by", action="append", choices=["project_id", "line_item", "api_key_id"], default=[])
parser.add_argument("--project-id", action="append", default=[])
parser.add_argument("--api-key-id", action="append", default=[])
+ parser.add_argument("--skip-configured-filters", action="store_true", help="Do not default to stored provider project/API key IDs.")
+ parser.add_argument("--skip-reprice", action="store_true", help="Do not recalculate stored event estimates before reconciliation.")
parser.add_argument("--dry-run", action="store_true", help="Fetch and calculate but do not persist final costs.")
args = parser.parse_args()
@@ -83,6 +85,12 @@ def main() -> int:
return 2
client = OpenAIOrganizationUsageClient(admin_api_key=admin_key, base_url=args.base_url)
allocation_method = "estimated_cost_proportional"
+ configured_filters = {"projectIds": [], "apiKeyIds": []} if args.skip_configured_filters else store.openai_reconciliation_filters()
+ project_ids = args.project_id or configured_filters["projectIds"]
+ api_key_ids = args.api_key_id or configured_filters["apiKeyIds"]
+ group_by = list(args.group_by)
+ if api_key_ids and "api_key_id" not in group_by:
+ group_by.append("api_key_id")
run_id = ""
if not args.dry_run:
@@ -95,16 +103,35 @@ def main() -> int:
allocation_method=allocation_method,
)
try:
+ repriced_count = 0
+ if not args.skip_reprice and not args.dry_run:
+ store.clear_openai_reconciliation_window(
+ start_time=args.start,
+ end_time=args.end,
+ project_ids=project_ids,
+ api_key_ids=api_key_ids,
+ )
+ repriced_count = store.reprice_openai_events_for_reconciliation(
+ start_time=args.start,
+ end_time=args.end,
+ project_ids=project_ids,
+ api_key_ids=api_key_ids,
+ )
payload = client.fetch_costs(
start_time=unix_seconds(args.start),
end_time=unix_seconds(args.end),
bucket_width=args.bucket_width,
- group_by=args.group_by,
- project_ids=args.project_id,
- api_key_ids=args.api_key_id,
+ group_by=group_by,
+ project_ids=project_ids,
+ api_key_ids=api_key_ids,
)
verified_cost = verified_cost_from_openai_payload(payload)
- events = store.list_openai_events_for_reconciliation(start_time=args.start, end_time=args.end)
+ events = store.list_openai_events_for_reconciliation(
+ start_time=args.start,
+ end_time=args.end,
+ project_ids=project_ids,
+ api_key_ids=api_key_ids,
+ )
effective_run_id = run_id or "dry-run"
updates = reconcile_cost_bucket(events, verified_cost_usd=verified_cost, reconciliation_run_id=effective_run_id)
estimated_cost = sum((update.estimated_cost_usd for update in updates), Decimal("0"))
@@ -116,11 +143,23 @@ def main() -> int:
verified_cost=verified_cost,
estimated_cost=estimated_cost,
event_count=len(updates),
- raw_provider_payload=payload,
+ raw_provider_payload={
+ "filters": {
+ "projectIds": project_ids,
+ "apiKeyIds": api_key_ids,
+ "groupBy": group_by,
+ "repricedEvents": repriced_count,
+ },
+ "payload": payload,
+ },
)
print(f"OpenAI reconciliation {'dry run' if args.dry_run else 'complete'}")
print(f"Window: {args.start.isoformat()} to {args.end.isoformat()}")
+ if project_ids or api_key_ids:
+ print(f"Filters: projects={len(project_ids)} api_keys={len(api_key_ids)} group_by={','.join(group_by) or 'none'}")
+ if not args.skip_reprice and not args.dry_run:
+ print(f"Repriced events: {repriced_count}")
print(f"Verified cost: ${verified_cost:.8f}")
print(f"Estimated event cost: ${estimated_cost:.8f}")
print(f"Events reconciled: {len(updates)}")