@agent-finops/core 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,24 @@
1
1
  import { createProviderConnectorStub, slugifySourceId } from "./sourceRegistry.js";
2
2
  import { redactSecrets } from "./discovery.js";
3
+ /**
4
+ * Trusted connector failure metadata. Provider prose remains untrusted and is
5
+ * used only as a sanitized human-readable message; callers classify failures
6
+ * from this product-authored code and the observed HTTP status instead.
7
+ */
8
+ export class ProviderConnectorError extends Error {
9
+ code;
10
+ status;
11
+ constructor(message, options) {
12
+ super(message);
13
+ this.name = "ProviderConnectorError";
14
+ this.code = options.code;
15
+ this.status = options.status;
16
+ }
17
+ }
18
+ export function isProviderAuthenticationError(error) {
19
+ return error instanceof ProviderConnectorError &&
20
+ error.code === "authentication_error";
21
+ }
3
22
  export function normalizeOpenAiCostResponse(response, options) {
4
23
  const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
5
24
  const records = [];
@@ -59,29 +78,115 @@ export function normalizeOpenAiCostResponse(response, options) {
59
78
  export function normalizeOpenAiUsageResponse(response, options) {
60
79
  const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
61
80
  const records = [];
62
- for (const bucket of data) {
63
- const startTime = typeof bucket.start_time === "number" ? bucket.start_time : 0;
81
+ for (const bucketValue of data) {
82
+ if (!isRecord(bucketValue) || !Array.isArray(bucketValue.results))
83
+ continue;
84
+ const startTime = validEpochSeconds(bucketValue.start_time);
85
+ if (typeof startTime !== "number")
86
+ continue;
64
87
  const timestamp = new Date(startTime * 1000).toISOString();
65
- for (const result of bucket.results ?? []) {
66
- const projectId = result.project_id ?? undefined;
67
- const userId = result.user_id ?? undefined;
68
- const apiKeyId = result.api_key_id ?? undefined;
69
- const model = result.model ?? "openai-usage";
70
- const inputTokens = nonNegativeIntegerValue(result.input_tokens) ?? 0;
71
- const outputTokens = nonNegativeIntegerValue(result.output_tokens) ?? 0;
72
- const cachedTokens = nonNegativeIntegerValue(result.input_cached_tokens) ?? 0;
73
- const audioInputTokens = nonNegativeIntegerValue(result.input_audio_tokens) ?? 0;
74
- const audioOutputTokens = nonNegativeIntegerValue(result.output_audio_tokens) ?? 0;
88
+ for (const resultValue of bucketValue.results) {
89
+ if (!isRecord(resultValue))
90
+ continue;
91
+ const result = resultValue;
92
+ const projectId = stringValue(result.project_id);
93
+ const userId = stringValue(result.user_id);
94
+ const apiKeyId = stringValue(result.api_key_id);
95
+ const model = stringValue(result.model) ?? "openai-usage";
96
+ const serviceTier = stringValue(result.service_tier);
97
+ const batch = typeof result.batch === "boolean" ? result.batch : undefined;
98
+ // OpenAI's organization completions Usage API defines these as
99
+ // inclusive totals across text, audio, image, cache reads, and cache
100
+ // writes. The modality/cache fields below are subsets for provenance;
101
+ // adding them again double-counts usage.
102
+ // https://developers.openai.com/cookbook/examples/completions_usage_api
103
+ const inputTokens = nonNegativeIntegerValue(result.input_tokens);
104
+ const outputTokens = nonNegativeIntegerValue(result.output_tokens);
105
+ // A request count does not prove a zero-token result. Preserve explicit
106
+ // 0/0 totals, but fail closed when either canonical total is absent or
107
+ // invalid so missing provider evidence never becomes a verified zero.
108
+ if (typeof inputTokens !== "number" || typeof outputTokens !== "number")
109
+ continue;
75
110
  const requestCount = nonNegativeIntegerValue(result.num_model_requests);
76
- if (inputTokens + outputTokens + audioInputTokens + audioOutputTokens === 0 && typeof requestCount !== "number")
111
+ if (inputTokens + outputTokens === 0 && !(typeof requestCount === "number" && requestCount > 0))
77
112
  continue;
113
+ const inputComponent = (value) => boundedTokenComponent(value, inputTokens);
114
+ const outputComponent = (value) => boundedTokenComponent(value, outputTokens);
115
+ const cacheReadTokens = inputComponent(result.input_cached_tokens);
116
+ const cachedComponent = (value) => {
117
+ const component = inputComponent(value);
118
+ return component !== undefined && (cacheReadTokens === undefined || component <= cacheReadTokens)
119
+ ? component
120
+ : undefined;
121
+ };
122
+ const inputAccountingValid = componentFamilyWithinParent([
123
+ result.input_uncached_tokens,
124
+ result.input_cache_write_tokens,
125
+ result.input_cached_tokens
126
+ ], inputTokens);
127
+ const inputModalitiesValid = componentFamilyWithinParent([
128
+ result.input_text_tokens,
129
+ result.input_image_tokens,
130
+ result.input_audio_tokens
131
+ ], inputTokens);
132
+ const cachedModalitiesPresent = [
133
+ result.input_cached_text_tokens,
134
+ result.input_cached_image_tokens,
135
+ result.input_cached_audio_tokens
136
+ ].some((value) => value !== undefined);
137
+ const cachedModalitiesValid = !cachedModalitiesPresent || (cacheReadTokens !== undefined && componentFamilyWithinParent([
138
+ result.input_cached_text_tokens,
139
+ result.input_cached_image_tokens,
140
+ result.input_cached_audio_tokens
141
+ ], cacheReadTokens));
142
+ const outputModalitiesValid = componentFamilyWithinParent([
143
+ result.output_text_tokens,
144
+ result.output_image_tokens,
145
+ result.output_audio_tokens
146
+ ], outputTokens);
78
147
  records.push({
79
- id: slugifySourceId(["openai-usage", String(startTime), projectId, userId, apiKeyId, model].filter(Boolean).join("-")),
148
+ id: slugifySourceId(["openai-usage", String(startTime), projectId, userId, apiKeyId, model, serviceTier, batch === undefined ? undefined : `batch-${batch}`].filter(Boolean).join("-")),
80
149
  timestamp,
81
150
  source: { id: options.sourceId, name: "OpenAI organization usage API", provider: "openai", confidence: "verified", observedFrom: options.observedFrom },
82
151
  model,
83
- inputTokens: inputTokens + audioInputTokens,
84
- outputTokens: outputTokens + audioOutputTokens,
152
+ inputTokens,
153
+ outputTokens,
154
+ ...(inputAccountingValid && inputComponent(result.input_uncached_tokens) !== undefined
155
+ ? { inputUncachedTokens: inputComponent(result.input_uncached_tokens) }
156
+ : {}),
157
+ ...(inputAccountingValid && inputComponent(result.input_cache_write_tokens) !== undefined
158
+ ? { inputCacheWriteTokens: inputComponent(result.input_cache_write_tokens) }
159
+ : {}),
160
+ ...(inputAccountingValid && cacheReadTokens !== undefined
161
+ ? { cacheReadTokens }
162
+ : {}),
163
+ ...(inputModalitiesValid && inputComponent(result.input_text_tokens) !== undefined
164
+ ? { inputTextTokens: inputComponent(result.input_text_tokens) }
165
+ : {}),
166
+ ...(inputModalitiesValid && inputComponent(result.input_image_tokens) !== undefined
167
+ ? { inputImageTokens: inputComponent(result.input_image_tokens) }
168
+ : {}),
169
+ ...(inputModalitiesValid && inputComponent(result.input_audio_tokens) !== undefined
170
+ ? { inputAudioTokens: inputComponent(result.input_audio_tokens) }
171
+ : {}),
172
+ ...(cachedModalitiesValid && cachedComponent(result.input_cached_text_tokens) !== undefined
173
+ ? { inputCachedTextTokens: cachedComponent(result.input_cached_text_tokens) }
174
+ : {}),
175
+ ...(cachedModalitiesValid && cachedComponent(result.input_cached_image_tokens) !== undefined
176
+ ? { inputCachedImageTokens: cachedComponent(result.input_cached_image_tokens) }
177
+ : {}),
178
+ ...(cachedModalitiesValid && cachedComponent(result.input_cached_audio_tokens) !== undefined
179
+ ? { inputCachedAudioTokens: cachedComponent(result.input_cached_audio_tokens) }
180
+ : {}),
181
+ ...(outputModalitiesValid && outputComponent(result.output_text_tokens) !== undefined
182
+ ? { outputTextTokens: outputComponent(result.output_text_tokens) }
183
+ : {}),
184
+ ...(outputModalitiesValid && outputComponent(result.output_image_tokens) !== undefined
185
+ ? { outputImageTokens: outputComponent(result.output_image_tokens) }
186
+ : {}),
187
+ ...(outputModalitiesValid && outputComponent(result.output_audio_tokens) !== undefined
188
+ ? { outputAudioTokens: outputComponent(result.output_audio_tokens) }
189
+ : {}),
85
190
  amountUsd: null,
86
191
  costConfidence: "missing",
87
192
  projectId,
@@ -90,12 +195,24 @@ export function normalizeOpenAiUsageResponse(response, options) {
90
195
  providerCostType: "openai_usage_evidence",
91
196
  usageGranularity: "usage_bucket",
92
197
  quantity: requestCount,
198
+ ...(serviceTier ? { serviceTier } : {}),
199
+ ...(batch !== undefined ? { batch } : {}),
93
200
  operation: "OpenAI completions usage evidence"
94
201
  });
95
202
  }
96
203
  }
97
204
  return records;
98
205
  }
206
+ function boundedTokenComponent(value, inclusiveTotal) {
207
+ const component = nonNegativeIntegerValue(value);
208
+ return typeof component === "number" && component <= inclusiveTotal ? component : undefined;
209
+ }
210
+ function componentFamilyWithinParent(values, parent) {
211
+ const components = values
212
+ .map(nonNegativeIntegerValue)
213
+ .filter((value) => typeof value === "number");
214
+ return components.reduce((sum, value) => sum + value, 0) <= parent;
215
+ }
99
216
  export function normalizeAnthropicClaudeCodeUsageResponse(response, options) {
100
217
  const rows = extractArray(response, "data");
101
218
  const records = [];
@@ -377,7 +494,10 @@ function redactResolvedCredentialError(error, credentialVariants) {
377
494
  // provider can splice ANSI bytes through an opaque credential so the first
378
495
  // literal replacement misses it and control stripping reconstructs it.
379
496
  const safeMessage = exactRedactCredentialValues(sanitizeProviderMessage(withoutResolvedCredential), credentialVariants).trim();
380
- return new Error(safeMessage || "Provider connector request failed without a safe error message.");
497
+ const message = safeMessage || "Provider connector request failed without a safe error message.";
498
+ return error instanceof ProviderConnectorError
499
+ ? new ProviderConnectorError(message, { code: error.code, status: error.status })
500
+ : new Error(message);
381
501
  }
382
502
  function redactResolvedCredentialValue(value, credentialVariants) {
383
503
  if (typeof value === "string") {
@@ -400,12 +520,15 @@ async function fetchOpenAi(input, token, fetcher, sourceId) {
400
520
  };
401
521
  const costFetch = await fetchPaginatedJson(fetcher, buildOpenAiCostsUrl(input.startTime, input.endTime), request, "openai", "OpenAI costs API");
402
522
  markMalformedCostRows(costFetch, "openai", "OpenAI costs API");
523
+ enforceOpenAiRequestedWindow(costFetch, input.startTime, input.endTime, "OpenAI costs API");
403
524
  const usageFetch = await fetchPaginatedJson(fetcher, buildOpenAiUsageUrl(input.startTime, input.endTime), request, "openai", "OpenAI usage API");
404
525
  markMalformedUsageRows(usageFetch, "openai", "OpenAI usage API");
405
- const records = [
526
+ enforceOpenAiRequestedWindow(usageFetch, input.startTime, input.endTime, "OpenAI usage API");
527
+ const normalizedRecords = [
406
528
  ...costFetch.pages.flatMap((page) => normalizeOpenAiCostResponse(page, { sourceId, observedFrom: "OpenAI organization costs API" })),
407
529
  ...usageFetch.pages.flatMap((page) => normalizeOpenAiUsageResponse(page, { sourceId, observedFrom: "OpenAI organization usage API" }))
408
530
  ];
531
+ const records = dedupeProviderRecords(normalizedRecords, [costFetch, usageFetch]);
409
532
  return providerResult("openai", sourceId, input.authReference, records, qaSummary("openai", [costFetch, usageFetch]), requestedCoverageInterval(input));
410
533
  }
411
534
  async function fetchAnthropic(input, token, fetcher, sourceId) {
@@ -587,7 +710,7 @@ async function fetchTextOrThrow(fetcher, url, request, provider, label) {
587
710
  return response.text();
588
711
  }
589
712
  const payload = await response.json().catch(() => undefined);
590
- lastError = new Error(providerPermissionPrompt(provider, label, response, payload));
713
+ lastError = providerRequestError(provider, label, response, payload);
591
714
  const retryable = response.status === 429 || response.status >= 500;
592
715
  if (!retryable || attempt === maxFetchRetries)
593
716
  break;
@@ -664,7 +787,20 @@ async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label)
664
787
  let stoppedBecause = "complete";
665
788
  let note;
666
789
  const maxPages = 50;
790
+ const seenUrls = new Set();
667
791
  for (let pageCount = 0; nextUrl && pageCount < maxPages; pageCount += 1) {
792
+ if (seenUrls.has(nextUrl)) {
793
+ stoppedBecause = "missing_cursor";
794
+ note = `Stopped after ${pages.length} page(s): provider repeated a pagination URL.`;
795
+ responseDrift.push({
796
+ label,
797
+ field: "next_page",
798
+ issue: "provider repeated a pagination cursor; duplicate pages were not fetched"
799
+ });
800
+ nextUrl = undefined;
801
+ break;
802
+ }
803
+ seenUrls.add(nextUrl);
668
804
  let response;
669
805
  try {
670
806
  response = await fetchJsonOrThrow(fetcher, nextUrl, request, provider, label);
@@ -721,6 +857,64 @@ async function fetchPaginatedJson(fetcher, initialUrl, request, provider, label)
721
857
  responseDrift
722
858
  };
723
859
  }
860
+ function enforceOpenAiRequestedWindow(fetchResult, requestedStart, requestedEnd, label) {
861
+ let excluded = 0;
862
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
863
+ fetchResult.pages = fetchResult.pages.map((page) => {
864
+ if (!isRecord(page) || !Array.isArray(page.data))
865
+ return page;
866
+ const data = page.data.filter((bucket) => {
867
+ if (!isRecord(bucket))
868
+ return true;
869
+ const start = validEpochSeconds(bucket.start_time);
870
+ const hasEnd = bucket.end_time !== undefined;
871
+ const end = hasEnd ? validEpochSeconds(bucket.end_time) : undefined;
872
+ const invalidEnd = hasEnd && (typeof end !== "number" || typeof start !== "number" || end <= start);
873
+ const missingRequiredEnd = typeof requestedEnd === "number" && typeof end !== "number";
874
+ const outsideStart = typeof start !== "number" || start < requestedStart ||
875
+ (typeof requestedEnd === "number" && start >= requestedEnd);
876
+ const outsideEnd = typeof requestedEnd === "number" && typeof end === "number" && end > requestedEnd;
877
+ const futureOpenEndedStart = requestedEnd === undefined && typeof start === "number" && start > nowEpochSeconds;
878
+ if (!invalidEnd && !missingRequiredEnd && !outsideStart && !outsideEnd && !futureOpenEndedStart)
879
+ return true;
880
+ excluded += 1;
881
+ fetchResult.responseDrift.push({
882
+ label,
883
+ field: "data[].start_time/end_time",
884
+ issue: "bucket boundary was missing, invalid, future-starting, or outside the requested interval; the bucket was excluded"
885
+ });
886
+ return false;
887
+ });
888
+ return { ...page, data };
889
+ });
890
+ if (excluded > 0)
891
+ fetchResult.coverageIncomplete = true;
892
+ }
893
+ function dedupeProviderRecords(records, fetches) {
894
+ const byId = new Map();
895
+ const conflicted = new Set();
896
+ for (const record of records) {
897
+ const existing = byId.get(record.id);
898
+ if (!existing && !conflicted.has(record.id)) {
899
+ byId.set(record.id, record);
900
+ continue;
901
+ }
902
+ const target = record.providerCostType === "openai_cost" ? fetches[0] : fetches[1];
903
+ target.coverageIncomplete = true;
904
+ target.responseDrift.push({
905
+ label: target.pagination.label,
906
+ field: "normalized records[].id",
907
+ issue: existing && JSON.stringify(existing) === JSON.stringify(record)
908
+ ? "duplicate provider record was excluded"
909
+ : "conflicting provider records shared one stable identity; all conflicting rows were excluded"
910
+ });
911
+ if (existing && JSON.stringify(existing) !== JSON.stringify(record)) {
912
+ byId.delete(record.id);
913
+ conflicted.add(record.id);
914
+ }
915
+ }
916
+ return Array.from(byId.values());
917
+ }
724
918
  function validatePaginationUrl(initialUrl, candidate) {
725
919
  try {
726
920
  const initial = new URL(initialUrl);
@@ -793,7 +987,7 @@ async function fetchJsonOrThrow(fetcher, url, request, provider, label) {
793
987
  if (response.ok) {
794
988
  return { payload, rateLimit: rateLimitFromHeaders(label, response.headers), headers: response.headers };
795
989
  }
796
- lastError = new Error(providerPermissionPrompt(provider, label, response, payload));
990
+ lastError = providerRequestError(provider, label, response, payload);
797
991
  // 429 and 5xx are transient: honor retry-after when present, otherwise
798
992
  // back off briefly and try again. 4xx auth/scope errors fail immediately.
799
993
  const retryable = response.status === 429 || response.status >= 500;
@@ -978,6 +1172,9 @@ function markMalformedUsageRows(fetchResult, provider, label) {
978
1172
  report(bucketPath, "usage response contained a malformed bucket or omitted its results array");
979
1173
  continue;
980
1174
  }
1175
+ if (typeof validEpochSeconds(bucketValue.start_time) !== "number") {
1176
+ report(`${bucketPath}.start_time`, "usage bucket start_time must be a non-negative whole-second timestamp; the bucket was excluded");
1177
+ }
981
1178
  for (const [resultIndex, resultValue] of bucketValue.results.entries()) {
982
1179
  const resultPath = `${bucketPath}.results[${resultIndex}]`;
983
1180
  if (!isRecord(resultValue)) {
@@ -1003,6 +1200,67 @@ function markMalformedUsageRows(fetchResult, provider, label) {
1003
1200
  checkInteger(resultValue[field], `${resultPath}.${field}`, "token count");
1004
1201
  }
1005
1202
  checkInteger(resultValue.num_model_requests, `${resultPath}.num_model_requests`, "quantity");
1203
+ const inputTotal = nonNegativeIntegerValue(resultValue.input_tokens);
1204
+ const outputTotal = nonNegativeIntegerValue(resultValue.output_tokens);
1205
+ const cachedTotal = nonNegativeIntegerValue(resultValue.input_cached_tokens);
1206
+ if (typeof inputTotal !== "number") {
1207
+ report(`${resultPath}.input_tokens`, "canonical input_tokens total is required; the usage row was excluded");
1208
+ }
1209
+ if (typeof outputTotal !== "number") {
1210
+ report(`${resultPath}.output_tokens`, "canonical output_tokens total is required; the usage row was excluded");
1211
+ }
1212
+ for (const field of [
1213
+ "input_uncached_tokens",
1214
+ "input_cache_write_tokens",
1215
+ "input_cached_tokens",
1216
+ "input_text_tokens",
1217
+ "input_image_tokens",
1218
+ "input_audio_tokens",
1219
+ "input_cached_text_tokens",
1220
+ "input_cached_image_tokens",
1221
+ "input_cached_audio_tokens"
1222
+ ]) {
1223
+ const component = nonNegativeIntegerValue(resultValue[field]);
1224
+ if (typeof component === "number" && typeof inputTotal === "number" && component > inputTotal) {
1225
+ report(`${resultPath}.${field}`, "input component exceeds the inclusive input_tokens total; the component was excluded");
1226
+ }
1227
+ }
1228
+ for (const field of ["output_text_tokens", "output_image_tokens", "output_audio_tokens"]) {
1229
+ const component = nonNegativeIntegerValue(resultValue[field]);
1230
+ if (typeof component === "number" && typeof outputTotal === "number" && component > outputTotal) {
1231
+ report(`${resultPath}.${field}`, "output component exceeds the inclusive output_tokens total; the component was excluded");
1232
+ }
1233
+ }
1234
+ for (const field of [
1235
+ "input_cached_text_tokens",
1236
+ "input_cached_image_tokens",
1237
+ "input_cached_audio_tokens"
1238
+ ]) {
1239
+ const component = nonNegativeIntegerValue(resultValue[field]);
1240
+ if (typeof component === "number" && typeof cachedTotal === "number" && component > cachedTotal) {
1241
+ report(`${resultPath}.${field}`, "cached component exceeds input_cached_tokens; the component was excluded");
1242
+ }
1243
+ }
1244
+ if (typeof cachedTotal !== "number" && [
1245
+ resultValue.input_cached_text_tokens,
1246
+ resultValue.input_cached_image_tokens,
1247
+ resultValue.input_cached_audio_tokens
1248
+ ].some((value) => typeof nonNegativeIntegerValue(value) === "number")) {
1249
+ report(`${resultPath}.input_cached_*_tokens`, "cached modality components require input_cached_tokens; the component family was excluded");
1250
+ }
1251
+ const reportFamilyOverflow = (fields, parent, parentField) => {
1252
+ if (typeof parent !== "number")
1253
+ return;
1254
+ const values = fields.map((field) => nonNegativeIntegerValue(resultValue[field]));
1255
+ const present = values.filter((value) => typeof value === "number");
1256
+ if (present.reduce((sum, value) => sum + value, 0) > parent) {
1257
+ report(`${resultPath}.${fields.join("+")}`, `component family exceeds ${parentField}; the contradictory component family was excluded`);
1258
+ }
1259
+ };
1260
+ reportFamilyOverflow(["input_uncached_tokens", "input_cache_write_tokens", "input_cached_tokens"], inputTotal, "input_tokens");
1261
+ reportFamilyOverflow(["input_text_tokens", "input_image_tokens", "input_audio_tokens"], inputTotal, "input_tokens");
1262
+ reportFamilyOverflow(["input_cached_text_tokens", "input_cached_image_tokens", "input_cached_audio_tokens"], cachedTotal, "input_cached_tokens");
1263
+ reportFamilyOverflow(["output_text_tokens", "output_image_tokens", "output_audio_tokens"], outputTotal, "output_tokens");
1006
1264
  }
1007
1265
  }
1008
1266
  continue;
@@ -1195,6 +1453,13 @@ function providerPermissionPrompt(provider, label, response, payload) {
1195
1453
  }
1196
1454
  return `${label} request failed with ${status}. ${rawMessage}`.trim();
1197
1455
  }
1456
+ function providerRequestError(provider, label, response, payload) {
1457
+ const authenticationFailure = response.status === 401 || response.status === 403;
1458
+ return new ProviderConnectorError(providerPermissionPrompt(provider, label, response, payload), {
1459
+ code: authenticationFailure ? "authentication_error" : "provider_request_error",
1460
+ status: response.status
1461
+ });
1462
+ }
1198
1463
  function extractProviderMessage(payload) {
1199
1464
  if (!isRecord(payload))
1200
1465
  return "";
@@ -1238,6 +1503,17 @@ export function summarizeProviderFinancials(records) {
1238
1503
  }
1239
1504
  return { providerReportedBilledUsd, apiEquivalentEstimatedUsd, providerEstimatedUsd, headlineUsd: null, headlineBasis: "unavailable" };
1240
1505
  }
1506
+ export function providerFinancialCompleteness(records, coverage) {
1507
+ const financials = summarizeProviderFinancials(records);
1508
+ const headlineConfidence = financials.headlineBasis === "provider_reported_billed_cost"
1509
+ ? "verified"
1510
+ : financials.headlineBasis === "unavailable"
1511
+ ? "missing"
1512
+ : "estimated";
1513
+ return coverage === "partial" && headlineConfidence !== "missing"
1514
+ ? "detected_unverified"
1515
+ : headlineConfidence;
1516
+ }
1241
1517
  /**
1242
1518
  * Keep evidence records available to callers, but never add estimates to a
1243
1519
  * provider's official billed total. This selection is intended for aggregate
@@ -1268,14 +1544,7 @@ function providerResult(provider, sourceId, authReference, records, qa, coverage
1268
1544
  const coverage = resolvedQa.coverage
1269
1545
  ?? (resolvedQa.pagination.every((pagination) => pagination.stoppedBecause === "complete") ? "complete" : "partial");
1270
1546
  const financials = summarizeProviderFinancials(records);
1271
- const headlineConfidence = financials.headlineBasis === "provider_reported_billed_cost"
1272
- ? "verified"
1273
- : financials.headlineBasis === "unavailable"
1274
- ? "missing"
1275
- : "estimated";
1276
- const completeness = coverage === "partial" && headlineConfidence !== "missing"
1277
- ? "detected_unverified"
1278
- : headlineConfidence;
1547
+ const completeness = providerFinancialCompleteness(records, coverage);
1279
1548
  return {
1280
1549
  provider,
1281
1550
  source: createProviderConnection({ provider, sourceId, authReference, verifiedRecordCount: records.length, totalUsd: financials.headlineUsd, completeness }),
@@ -1328,7 +1597,7 @@ export function createProviderConnection(input) {
1328
1597
  validationCoverage: validationCoverageForCompletedProviderSync(input.provider),
1329
1598
  financialEvidence,
1330
1599
  authReference: input.authReference,
1331
- fieldsMissing: [],
1600
+ fieldsMissing: financialEvidence === "missing" ? ["provider financial headline"] : [],
1332
1601
  scope: `${source.scope} Last successful pull produced ${input.verifiedRecordCount} record(s); financial evidence: ${financialEvidence}; financial headline: ${total}.`
1333
1602
  };
1334
1603
  }
@@ -1345,15 +1614,15 @@ function formatProviderUsd(value) {
1345
1614
  }
1346
1615
  export function resolveTokenReference(reference, env = process.env) {
1347
1616
  if (!reference.startsWith("env:")) {
1348
- throw new Error("Provider auth reference must be a local reference such as env:OPENAI_ADMIN_KEY; raw secrets are not accepted.");
1617
+ throw new ProviderConnectorError("Provider auth reference must be a local reference such as env:OPENAI_ADMIN_KEY; raw secrets are not accepted.", { code: "authentication_error" });
1349
1618
  }
1350
1619
  const envName = reference.slice("env:".length);
1351
1620
  if (!/^[A-Z0-9_]+$/.test(envName)) {
1352
- throw new Error("Provider auth env reference must use an uppercase environment variable name.");
1621
+ throw new ProviderConnectorError("Provider auth env reference must use an uppercase environment variable name.", { code: "authentication_error" });
1353
1622
  }
1354
1623
  const value = env[envName];
1355
1624
  if (!value) {
1356
- throw new Error(`Provider auth reference ${reference} is not set in the local environment.`);
1625
+ throw new ProviderConnectorError(`Provider auth reference ${reference} is not set in the local environment.`, { code: "authentication_error" });
1357
1626
  }
1358
1627
  return value;
1359
1628
  }
@@ -1378,6 +1647,8 @@ function buildOpenAiUsageUrl(startTime, endTime) {
1378
1647
  url.searchParams.append("group_by", "user_id");
1379
1648
  url.searchParams.append("group_by", "api_key_id");
1380
1649
  url.searchParams.append("group_by", "model");
1650
+ url.searchParams.append("group_by", "batch");
1651
+ url.searchParams.append("group_by", "service_tier");
1381
1652
  if (endTime !== undefined)
1382
1653
  url.searchParams.set("end_time", String(endTime));
1383
1654
  return url.toString();
@@ -1449,7 +1720,7 @@ function stringValue(value) {
1449
1720
  }
1450
1721
  function validEpochSeconds(value) {
1451
1722
  const seconds = numberValue(value);
1452
- return typeof seconds === "number" && seconds >= 0 && Number.isFinite(new Date(seconds * 1000).getTime())
1723
+ return typeof seconds === "number" && Number.isInteger(seconds) && seconds >= 0 && Number.isFinite(new Date(seconds * 1000).getTime())
1453
1724
  ? seconds
1454
1725
  : undefined;
1455
1726
  }
@@ -0,0 +1,8 @@
1
+ export declare const generatedProviderContractStates: {
2
+ readonly anthropic: "current";
3
+ readonly cursor: "current";
4
+ readonly "gemini-cli": "current";
5
+ readonly "github-copilot": "current";
6
+ readonly openai: "current";
7
+ };
8
+ //# sourceMappingURL=providerContractStates.generated.d.ts.map
@@ -0,0 +1,9 @@
1
+ // GENERATED by scripts/generate-provider-contract-docs.mjs. Do not edit by hand.
2
+ export const generatedProviderContractStates = {
3
+ "anthropic": "current",
4
+ "cursor": "current",
5
+ "gemini-cli": "current",
6
+ "github-copilot": "current",
7
+ "openai": "current",
8
+ };
9
+ //# sourceMappingURL=providerContractStates.generated.js.map
package/dist/schema.d.ts CHANGED
@@ -69,6 +69,24 @@ export declare const usageRecordSchema: z.ZodObject<{
69
69
  model: z.ZodString;
70
70
  inputTokens: z.ZodNumber;
71
71
  outputTokens: z.ZodNumber;
72
+ cacheReadTokens: z.ZodOptional<z.ZodNumber>;
73
+ inputUncachedTokens: z.ZodOptional<z.ZodNumber>;
74
+ inputCacheWriteTokens: z.ZodOptional<z.ZodNumber>;
75
+ inputTextTokens: z.ZodOptional<z.ZodNumber>;
76
+ inputImageTokens: z.ZodOptional<z.ZodNumber>;
77
+ inputAudioTokens: z.ZodOptional<z.ZodNumber>;
78
+ inputCachedTextTokens: z.ZodOptional<z.ZodNumber>;
79
+ inputCachedImageTokens: z.ZodOptional<z.ZodNumber>;
80
+ inputCachedAudioTokens: z.ZodOptional<z.ZodNumber>;
81
+ outputTextTokens: z.ZodOptional<z.ZodNumber>;
82
+ outputImageTokens: z.ZodOptional<z.ZodNumber>;
83
+ outputAudioTokens: z.ZodOptional<z.ZodNumber>;
84
+ serviceTier: z.ZodOptional<z.ZodString>;
85
+ batch: z.ZodOptional<z.ZodBoolean>;
86
+ thoughtTokens: z.ZodOptional<z.ZodNumber>;
87
+ toolTokens: z.ZodOptional<z.ZodNumber>;
88
+ reportedTotalTokens: z.ZodOptional<z.ZodNumber>;
89
+ sourceVersions: z.ZodOptional<z.ZodArray<z.ZodString>>;
72
90
  amountUsd: z.ZodNullable<z.ZodNumber>;
73
91
  costConfidence: z.ZodEnum<{
74
92
  verified: "verified";
package/dist/schema.js CHANGED
@@ -47,8 +47,34 @@ export const usageRecordSchema = z.object({
47
47
  timestamp: z.string().datetime({ offset: true }),
48
48
  source: spendSourceSchema,
49
49
  model: z.string().min(1),
50
+ /** Inclusive input-side total. Gemini includes cached + tool tokens here. */
50
51
  inputTokens: z.number().int().nonnegative(),
52
+ /** Inclusive output-side total. Gemini includes thought tokens here. */
51
53
  outputTokens: z.number().int().nonnegative(),
54
+ /**
55
+ * Optional component subsets retained as provenance. These fields are
56
+ * already included in inputTokens/outputTokens and must not be added again.
57
+ */
58
+ cacheReadTokens: z.number().int().nonnegative().optional(),
59
+ inputUncachedTokens: z.number().int().nonnegative().optional(),
60
+ inputCacheWriteTokens: z.number().int().nonnegative().optional(),
61
+ inputTextTokens: z.number().int().nonnegative().optional(),
62
+ inputImageTokens: z.number().int().nonnegative().optional(),
63
+ inputAudioTokens: z.number().int().nonnegative().optional(),
64
+ inputCachedTextTokens: z.number().int().nonnegative().optional(),
65
+ inputCachedImageTokens: z.number().int().nonnegative().optional(),
66
+ inputCachedAudioTokens: z.number().int().nonnegative().optional(),
67
+ outputTextTokens: z.number().int().nonnegative().optional(),
68
+ outputImageTokens: z.number().int().nonnegative().optional(),
69
+ outputAudioTokens: z.number().int().nonnegative().optional(),
70
+ /** Provider grouping dimensions retained so bucket identities stay unique. */
71
+ serviceTier: z.string().min(1).optional(),
72
+ batch: z.boolean().optional(),
73
+ thoughtTokens: z.number().int().nonnegative().optional(),
74
+ toolTokens: z.number().int().nonnegative().optional(),
75
+ reportedTotalTokens: z.number().int().nonnegative().optional(),
76
+ /** Sanitized CLI/source versions observed within this aggregate. */
77
+ sourceVersions: z.array(z.string().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9.+_-]*$/)).max(8).optional(),
52
78
  amountUsd: z.number().nonnegative().nullable(),
53
79
  costConfidence: costConfidenceSchema,
54
80
  clientId: z.string().min(1).optional(),
@@ -1,4 +1,5 @@
1
1
  import type { CostConfidence, UsageRecord } from "./schema.js";
2
+ import type { SourceRegistry } from "./sourceRegistry.js";
2
3
  import type { LocalAgentFormatId } from "./localAgentFormats/types.js";
3
4
  /**
4
5
  * How thoroughly an ingestion path itself has been exercised.
@@ -12,6 +13,8 @@ export type SourceValidationCoverage = typeof sourceValidationCoverageValues[num
12
13
  export type FinancialEvidenceStatus = CostConfidence;
13
14
  export declare const sourceFreshnessStatusValues: readonly ["fresh", "stale", "not_checked"];
14
15
  export type SourceFreshnessStatus = typeof sourceFreshnessStatusValues[number];
16
+ export declare const providerContractStateValues: readonly ["current", "stale_contract"];
17
+ export type ProviderContractState = typeof providerContractStateValues[number];
15
18
  export type SourceStatusId = LocalAgentFormatId | "openai" | "anthropic" | "cursor" | "github-copilot";
16
19
  export type SourceStatusDefinition = {
17
20
  id: SourceStatusId;
@@ -19,6 +22,8 @@ export type SourceStatusDefinition = {
19
22
  validationCoverage: SourceValidationCoverage;
20
23
  validationNote: string;
21
24
  staleAfterHours: number;
25
+ /** Review state of the provider/parser financial semantics used by this source. */
26
+ contractState?: ProviderContractState;
22
27
  };
23
28
  export type SourceStatusObservation = {
24
29
  id: SourceStatusId;
@@ -32,6 +37,8 @@ export type SourceStatusObservation = {
32
37
  lastError?: string;
33
38
  /** Runtime failures may override the shipped validation baseline. */
34
39
  validationCoverage?: SourceValidationCoverage;
40
+ /** A drift monitor or reviewed contract update may fail this source closed. */
41
+ contractState?: ProviderContractState;
35
42
  };
36
43
  export type SourceStatusFreshness = {
37
44
  status: SourceFreshnessStatus;
@@ -46,6 +53,7 @@ export type SourceStatus = {
46
53
  validationNote: string;
47
54
  financialEvidence: FinancialEvidenceStatus;
48
55
  financialEvidenceNote: string;
56
+ contractState?: ProviderContractState;
49
57
  freshness: SourceStatusFreshness;
50
58
  lastError?: string;
51
59
  };
@@ -55,12 +63,26 @@ export type SourceStatus = {
55
63
  * reconciliation.
56
64
  */
57
65
  export declare const sourceStatusDefinitions: readonly SourceStatusDefinition[];
58
- export declare function buildSourceStatuses(observations?: readonly SourceStatusObservation[], now?: Date): SourceStatus[];
66
+ export declare function buildSourceStatuses(observations?: readonly SourceStatusObservation[], now?: Date, definitions?: readonly SourceStatusDefinition[]): SourceStatus[];
59
67
  /**
60
68
  * Reduce one source's current rows to the evidence label used for its headline.
61
69
  * Verified billed cost wins; otherwise estimates win over an unpriced signal.
62
70
  */
63
71
  export declare function financialEvidenceForRecords(records: readonly UsageRecord[]): FinancialEvidenceStatus;
72
+ /**
73
+ * Apply the reviewed provider-contract gate before any connected financial
74
+ * calculation. Local transcript evidence is unchanged. When a provider's
75
+ * financial semantics are stale, its priced rows remain present for audit and
76
+ * attribution but cannot carry a dollar amount or proof-level confidence.
77
+ */
78
+ export declare function applyProviderContractGate(records: readonly UsageRecord[], definitions?: readonly SourceStatusDefinition[]): UsageRecord[];
79
+ /**
80
+ * Project persisted source metadata through the same fail-closed contract
81
+ * gate as records. This is read-time only: the signed local receipt remains
82
+ * byte-exact, while an upgraded release cannot repeat an obsolete verified
83
+ * claim from an older sources.json.
84
+ */
85
+ export declare function applyProviderContractGateToSourceRegistry(registry: SourceRegistry, definitions?: readonly SourceStatusDefinition[]): SourceRegistry;
64
86
  /** Stable, plain-text formatter shared by terminal surfaces. */
65
87
  export declare function formatSourceStatuses(statuses: readonly SourceStatus[]): string;
66
88
  //# sourceMappingURL=sourceStatus.d.ts.map