@oh-my-pi/pi-catalog 17.4.1 → 17.4.2

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.
@@ -5,6 +5,17 @@ export interface ModelCacheProviderIdOptions {
5
5
  baseUrl?: string;
6
6
  }
7
7
 
8
+ const CREDENTIAL_SCOPED_MODEL_CACHE_PROVIDERS: Readonly<Record<string, true>> = {
9
+ "opencode-go": true,
10
+ "opencode-zen": true,
11
+ "github-copilot": true,
12
+ };
13
+
14
+ /** Whether a provider's model-cache namespace requires its resolved credential. */
15
+ export function isCredentialScopedModelCacheProvider(providerId: string): boolean {
16
+ return CREDENTIAL_SCOPED_MODEL_CACHE_PROVIDERS[providerId] === true;
17
+ }
18
+
8
19
  export function getDefaultModelDiscoveryBaseUrl(providerId: string): string | undefined {
9
20
  switch (providerId) {
10
21
  case "ollama":
@@ -32,7 +32,7 @@
32
32
  * generator, and the model-manager merge point.
33
33
  */
34
34
  import { buildCompat, buildModel } from "./build";
35
- import { Effort } from "./effort";
35
+ import { Effort, THINKING_EFFORTS } from "./effort";
36
36
  import { stripThinkingVariantToken } from "./identity/family";
37
37
  import { resolveModelThinking } from "./model-thinking";
38
38
  import type { Api, Model, ModelSpec, Provider, ThinkingConfig } from "./types";
@@ -775,6 +775,147 @@ export const CURSOR_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
775
775
  ],
776
776
  };
777
777
 
778
+ type CursorTierToken = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
779
+
780
+ interface CursorTierMember<TSpec extends VariantSpecLike> {
781
+ baseId: string;
782
+ fast: boolean;
783
+ spec: TSpec;
784
+ tier: CursorTierToken;
785
+ }
786
+
787
+ const CURSOR_TIER_ID_PATTERN = /^(.+?)-(extra-high|none|minimal|low|medium|high|xhigh|max)(-fast)?$/;
788
+ const CURSOR_TIER_BASE_PATTERN = /-(extra-high|none|minimal|low|medium|high|xhigh|max)$/;
789
+ const CURSOR_THINKING_TOKEN_PATTERN = /(^|-)thinking($|-)/;
790
+ const CURSOR_TIER_BY_TOKEN: Readonly<Record<string, CursorTierToken | undefined>> = {
791
+ none: "none",
792
+ minimal: "minimal",
793
+ low: "low",
794
+ medium: "medium",
795
+ high: "high",
796
+ "extra-high": "xhigh",
797
+ xhigh: "xhigh",
798
+ max: "max",
799
+ };
800
+
801
+ /** Whether an existing logical row already routes every live member in `members`. */
802
+ function collapsedCursorLogicalMatches<TSpec extends VariantSpecLike>(
803
+ spec: TSpec,
804
+ members: readonly CursorTierMember<TSpec>[],
805
+ ): boolean {
806
+ const routing = spec.thinking?.effortRouting;
807
+ if (!routing) return false;
808
+ for (const member of members) {
809
+ if (spec.requestModelId === member.spec.id) continue;
810
+ let matched = false;
811
+ for (const effort of VARIANT_ROUTING_KEYS) {
812
+ if (routing[effort] === member.spec.id) {
813
+ matched = true;
814
+ break;
815
+ }
816
+ }
817
+ if (!matched) return false;
818
+ }
819
+ return true;
820
+ }
821
+
822
+ /**
823
+ * Derive safe Cursor per-effort families from live wire ids. A family is
824
+ * intentionally left expanded when its base is an independent live SKU, any
825
+ * member already has a thinking ladder, member metadata differs, or a tier
826
+ * token is also part of a product name.
827
+ */
828
+ function deriveCursorEffortFamilies<TSpec extends VariantSpecLike>(specs: readonly TSpec[]): EffortVariantFamily[] {
829
+ const byId = new Map<string, TSpec>();
830
+ const groups = new Map<string, CursorTierMember<TSpec>[]>();
831
+ const candidateBases = new Set<string>();
832
+
833
+ for (const spec of specs) {
834
+ if (!byId.has(spec.id)) byId.set(spec.id, spec);
835
+ const match = CURSOR_TIER_ID_PATTERN.exec(spec.id);
836
+ if (!match) continue;
837
+ const baseId = match[1];
838
+ const tier = CURSOR_TIER_BY_TOKEN[match[2] ?? ""];
839
+ const fast = match[3] !== undefined;
840
+ if (!baseId || !tier) continue;
841
+ const member = { baseId, fast, spec, tier };
842
+ const key = `${baseId}\0${fast ? "fast" : "standard"}`;
843
+ const group = groups.get(key);
844
+ if (group) {
845
+ group.push(member);
846
+ } else {
847
+ groups.set(key, [member]);
848
+ }
849
+ candidateBases.add(baseId);
850
+ }
851
+
852
+ const unsafeBases = new Set<string>();
853
+ for (const group of groups.values()) {
854
+ const first = group[0];
855
+ if (!first) continue;
856
+ const { baseId } = first;
857
+ const standardGroup = groups.get(`${baseId}\0standard`) ?? [];
858
+ const standardBase = byId.get(baseId);
859
+ const laneBase = byId.get(`${baseId}${first.fast ? "-fast" : ""}`);
860
+ const independentStandardBase =
861
+ standardBase !== undefined && !collapsedCursorLogicalMatches(standardBase, standardGroup);
862
+ const independentLaneBase = laneBase !== undefined && !collapsedCursorLogicalMatches(laneBase, group);
863
+ if (
864
+ independentStandardBase ||
865
+ independentLaneBase ||
866
+ new Set(group.map(member => member.tier)).size !== group.length ||
867
+ CURSOR_TIER_BASE_PATTERN.test(baseId) ||
868
+ CURSOR_THINKING_TOKEN_PATTERN.test(baseId) ||
869
+ candidateBases.has(`${baseId}-thinking`) ||
870
+ byId.has(`${baseId}-thinking`) ||
871
+ byId.has(`${baseId}-thinking-fast`) ||
872
+ byId.has(`${baseId}-fast-thinking`) ||
873
+ group.some(member => member.spec.thinking !== undefined || member.spec.requestModelId !== undefined) ||
874
+ group.some(member => candidateBases.has(`${baseId}-${member.tier}`))
875
+ ) {
876
+ unsafeBases.add(baseId);
877
+ }
878
+ }
879
+
880
+ const families: EffortVariantFamily[] = [];
881
+ for (const group of groups.values()) {
882
+ const first = group[0];
883
+ if (!first || group.length < 2 || unsafeBases.has(first.baseId)) continue;
884
+ if (
885
+ group.some(
886
+ member =>
887
+ member.spec.api !== first.spec.api ||
888
+ member.spec.baseUrl !== first.spec.baseUrl ||
889
+ member.spec.contextWindow !== first.spec.contextWindow ||
890
+ member.spec.maxTokens !== first.spec.maxTokens ||
891
+ member.spec.cursorMaxMode !== first.spec.cursorMaxMode ||
892
+ !Bun.deepEquals(member.spec.cost, first.spec.cost) ||
893
+ !Bun.deepEquals(member.spec.compat, first.spec.compat),
894
+ )
895
+ ) {
896
+ continue;
897
+ }
898
+
899
+ const routes: TierRoutes = {};
900
+ for (const member of group) {
901
+ if (member.tier === "none") {
902
+ routes.off = member.spec.id;
903
+ } else {
904
+ routes[member.tier] = member.spec.id;
905
+ }
906
+ }
907
+ const efforts = THINKING_EFFORTS.filter(effort => routes[effort] !== undefined);
908
+ if (efforts.length === 0) continue;
909
+ const strippedName = first.spec.name
910
+ .replace(/\s+(extra-high|none|minimal|low|medium|high|xhigh|max)(\s+fast)?$/i, "")
911
+ .trim();
912
+ const baseName = strippedName === first.spec.id ? first.baseId : strippedName || first.baseId;
913
+ const suffix = first.fast ? "-fast" : "";
914
+ families.push(tierFamily(`${first.baseId}${suffix}`, `${baseName}${first.fast ? " Fast" : ""}`, routes, efforts));
915
+ }
916
+ return families;
917
+ }
918
+
778
919
  /** Provider id → hand collapse table. The CCA providers diverge on thinking transport. */
779
920
  export const VARIANT_COLLAPSE_TABLES: Readonly<Record<string, VariantCollapseTable>> = {
780
921
  "google-antigravity": ANTIGRAVITY_VARIANT_COLLAPSE_TABLE,
@@ -1160,10 +1301,59 @@ export function collapseEffortVariants<TSpec extends VariantSpecLike>(
1160
1301
  }
1161
1302
 
1162
1303
  /**
1163
- * Collapse a full mixed-provider list: per provider, the hand table (when
1164
- * registered) plus the automatic `X`/`X-thinking` pair rule. Used by the
1165
- * catalog generator; the runtime equivalent lives at the model-manager merge
1166
- * point. Output is regrouped by provider — callers re-sort.
1304
+ * Re-key model-to-model configuration after collapse removes a referenced
1305
+ * member id. Qualified targets keep their provider; bare targets remain bare.
1306
+ */
1307
+ function retargetCollapsedModelReferences<TSpec extends VariantSpecLike>(specs: TSpec[]): void {
1308
+ const liveIdsByProvider = new Map<string, Set<string>>();
1309
+ for (const spec of specs) {
1310
+ const provider = spec.provider.toLowerCase();
1311
+ let liveIds = liveIdsByProvider.get(provider);
1312
+ if (!liveIds) {
1313
+ liveIds = new Set<string>();
1314
+ liveIdsByProvider.set(provider, liveIds);
1315
+ }
1316
+ liveIds.add(spec.id.toLowerCase());
1317
+ }
1318
+
1319
+ for (let index = 0; index < specs.length; index++) {
1320
+ const spec = specs[index];
1321
+ if (!spec) continue;
1322
+ const contextPromotionTarget = resolveCollapsedModelReference(
1323
+ spec.contextPromotionTarget,
1324
+ spec.provider,
1325
+ liveIdsByProvider,
1326
+ );
1327
+ const compactionModel = resolveCollapsedModelReference(spec.compactionModel, spec.provider, liveIdsByProvider);
1328
+ if (contextPromotionTarget === spec.contextPromotionTarget && compactionModel === spec.compactionModel) continue;
1329
+ specs[index] = { ...spec, contextPromotionTarget, compactionModel };
1330
+ }
1331
+ }
1332
+
1333
+ function resolveCollapsedModelReference(
1334
+ target: string | undefined,
1335
+ currentProvider: Provider,
1336
+ liveIdsByProvider: ReadonlyMap<string, ReadonlySet<string>>,
1337
+ ): string | undefined {
1338
+ if (target === undefined) return undefined;
1339
+ const separator = target.indexOf("/");
1340
+ const provider = separator >= 0 ? target.slice(0, separator) : currentProvider;
1341
+ const providerId = provider.toLowerCase();
1342
+ const modelId = separator >= 0 ? target.slice(separator + 1) : target;
1343
+ const normalizedModelId = modelId.trim().toLowerCase();
1344
+ const liveIds = liveIdsByProvider.get(providerId);
1345
+ if (liveIds?.has(normalizedModelId)) return target;
1346
+ const alias = resolveRegisteredVariantAlias(provider, normalizedModelId);
1347
+ if (alias === undefined || !liveIds?.has(alias.toLowerCase())) return target;
1348
+ return separator >= 0 ? `${provider}/${alias}` : alias;
1349
+ }
1350
+
1351
+ /**
1352
+ * Collapse a full mixed-provider list: per provider, the hand table, Cursor's
1353
+ * conservative live effort-sibling rule, and the automatic `X`/`X-thinking`
1354
+ * pair rule. Used by the catalog generator; the runtime equivalent lives at
1355
+ * the model-manager merge point. Output is regrouped by provider — callers
1356
+ * re-sort.
1167
1357
  */
1168
1358
  export function collapseEffortVariantsAcrossProviders<TSpec extends VariantSpecLike>(specs: readonly TSpec[]): TSpec[] {
1169
1359
  const byProvider = new Map<string, TSpec[]>();
@@ -1179,12 +1369,20 @@ export function collapseEffortVariantsAcrossProviders<TSpec extends VariantSpecL
1179
1369
  for (const [provider, slice] of byProvider) {
1180
1370
  const table = VARIANT_COLLAPSE_TABLES[provider];
1181
1371
  let result = table ? collapseEffortVariants(slice, table) : slice;
1372
+ if (provider === "cursor") {
1373
+ const cursorDerived = deriveCursorEffortFamilies(result);
1374
+ if (cursorDerived.length > 0) {
1375
+ result = collapseEffortVariants(result, { families: cursorDerived });
1376
+ }
1377
+ }
1182
1378
  const derived = deriveThinkingPairFamilies(result, table);
1183
1379
  if (derived.length > 0) {
1184
1380
  result = collapseEffortVariants(result, { families: derived });
1185
1381
  }
1382
+ registerCollapsedVariantAliases(provider, result);
1186
1383
  out.push(...result);
1187
1384
  }
1385
+ retargetCollapsedModelReferences(out);
1188
1386
  return out;
1189
1387
  }
1190
1388
 
@@ -1208,83 +1406,125 @@ interface VariantAliasIndex {
1208
1406
  /** lowercased retired id → replacement model id. */
1209
1407
  forward: Map<string, string>;
1210
1408
  /** replacement model id → retired ids that resolve to it. */
1211
- reverse: Map<string, readonly string[]>;
1212
- /** Collapsed logical ids declared by the table. */
1409
+ reverse: Map<string, string[]>;
1410
+ /** Collapsed logical ids declared by the table or observed at runtime. */
1213
1411
  familyIds: Set<string>;
1214
1412
  }
1215
1413
 
1414
+ const dynamicAliasIndexes = new Map<string, VariantAliasIndex>();
1415
+ const VARIANT_ROUTING_KEYS: readonly (Effort | "off")[] = ["off", ...THINKING_EFFORTS];
1416
+
1216
1417
  const kAliasIndex = Symbol("variant-collapse.aliasIndex");
1217
1418
 
1218
1419
  interface TableWithAliasIndex extends VariantCollapseTable {
1219
1420
  [kAliasIndex]?: VariantAliasIndex;
1220
1421
  }
1221
1422
 
1423
+ function createAliasIndex(): VariantAliasIndex {
1424
+ return {
1425
+ forward: new Map<string, string>(),
1426
+ reverse: new Map<string, string[]>(),
1427
+ familyIds: new Set<string>(),
1428
+ };
1429
+ }
1430
+
1431
+ function addVariantAlias(index: VariantAliasIndex, from: string, to: string): boolean {
1432
+ if (from === to || index.forward.has(from.toLowerCase())) return false;
1433
+ index.forward.set(from.toLowerCase(), to);
1434
+ const sources = index.reverse.get(to);
1435
+ if (sources) {
1436
+ sources.push(from);
1437
+ } else {
1438
+ index.reverse.set(to, [from]);
1439
+ }
1440
+ return true;
1441
+ }
1442
+
1443
+ /**
1444
+ * Persist aliases embedded in collapsed routing so generated catalog rows and
1445
+ * newly discovered families expose the same selector migrations as hand tables.
1446
+ */
1447
+ function registerCollapsedVariantAliases(provider: Provider, specs: readonly VariantSpecLike[]): void {
1448
+ const providerId = provider.toLowerCase();
1449
+ let index = dynamicAliasIndexes.get(providerId);
1450
+ for (const spec of specs) {
1451
+ const routing = spec.thinking?.effortRouting;
1452
+ if (!routing) continue;
1453
+ let registered = false;
1454
+ for (const effort of VARIANT_ROUTING_KEYS) {
1455
+ const source = routing[effort];
1456
+ if (!source || source === spec.id) continue;
1457
+ index ??= createAliasIndex();
1458
+ registered = addVariantAlias(index, source, spec.id) || registered;
1459
+ }
1460
+ if (spec.requestModelId && spec.requestModelId !== spec.id) {
1461
+ index ??= createAliasIndex();
1462
+ registered = addVariantAlias(index, spec.requestModelId, spec.id) || registered;
1463
+ }
1464
+ if (registered) index?.familyIds.add(spec.id);
1465
+ }
1466
+ if (index) dynamicAliasIndexes.set(providerId, index);
1467
+ }
1468
+
1469
+ function resolveRegisteredVariantAlias(provider: Provider, normalizedModelId: string): string | undefined {
1470
+ const providerId = provider.toLowerCase();
1471
+ const table = VARIANT_COLLAPSE_TABLES[provider] ?? VARIANT_COLLAPSE_TABLES[providerId];
1472
+ return (
1473
+ (table ? getAliasIndex(table).forward.get(normalizedModelId) : undefined) ??
1474
+ dynamicAliasIndexes.get(providerId)?.forward.get(normalizedModelId)
1475
+ );
1476
+ }
1477
+
1222
1478
  function getAliasIndex(table: VariantCollapseTable): VariantAliasIndex {
1223
1479
  const tagged = table as TableWithAliasIndex;
1224
1480
  const cached = tagged[kAliasIndex];
1225
1481
  if (cached) return cached;
1226
- const forward = new Map<string, string>();
1227
- const reverse = new Map<string, string[]>();
1228
- const add = (from: string, to: string) => {
1229
- if (from === to) return;
1230
- forward.set(from.toLowerCase(), to);
1231
- const sources = reverse.get(to);
1232
- if (sources) {
1233
- sources.push(from);
1234
- } else {
1235
- reverse.set(to, [from]);
1236
- }
1237
- };
1238
- const familyIds = new Set<string>();
1482
+ const index = createAliasIndex();
1239
1483
  for (const family of table.families) {
1240
- familyIds.add(family.id);
1241
- for (const member of family.members) add(member, family.id);
1242
- for (const alias of family.extraAliases ?? []) add(alias, family.id);
1484
+ index.familyIds.add(family.id);
1485
+ for (const member of family.members) addVariantAlias(index, member, family.id);
1486
+ for (const alias of family.extraAliases ?? []) addVariantAlias(index, alias, family.id);
1243
1487
  }
1244
- const index: VariantAliasIndex = { forward, reverse, familyIds };
1245
1488
  tagged[kAliasIndex] = index;
1246
1489
  return index;
1247
1490
  }
1248
1491
 
1249
1492
  /**
1250
1493
  * Resolve a retired effort-tier variant id (collapsed member, recycled id) to
1251
- * its replacement model id for `provider` via the hand table. Returns
1252
- * `undefined` when the id is not a known alias; derived `X-thinking` members
1253
- * resolve through `stripThinkingVariantToken` instead. Callers must try an
1254
- * exact model lookup first — a live model always wins over an alias.
1494
+ * its replacement model id for `provider` via hand-table or registered live
1495
+ * aliases. Returns `undefined` when the id is not a known alias; derived
1496
+ * `X-thinking` members also resolve through `stripThinkingVariantToken`.
1497
+ * Callers must try an exact model lookup first — a live model always wins over
1498
+ * an alias.
1255
1499
  */
1256
1500
  export function resolveVariantAlias(provider: Provider, modelId: string): string | undefined {
1257
- const table = VARIANT_COLLAPSE_TABLES[provider] ?? VARIANT_COLLAPSE_TABLES[provider.toLowerCase()];
1258
- if (!table) return undefined;
1259
- return getAliasIndex(table).forward.get(modelId.trim().toLowerCase());
1501
+ return resolveRegisteredVariantAlias(provider, modelId.trim().toLowerCase());
1260
1502
  }
1261
1503
 
1262
1504
  /** Bare-id alias hit: replacement id plus the providers declaring it. */
1263
1505
  export interface BareVariantAliasHit {
1264
1506
  id: string;
1265
- /** Providers whose table declares the alias — candidates from these win ties. */
1507
+ /** Providers declaring the alias — candidates from these win ties. */
1266
1508
  providers: readonly Provider[];
1267
1509
  }
1268
1510
 
1269
1511
  /**
1270
- * Provider-agnostic hand-table alias lookup for bare-id selectors. Returns
1271
- * the declaring providers so callers can prefer their models when the
1272
- * replacement id exists on unrelated providers too (e.g. a retired Cursor
1273
- * tier id must not resolve to `openai/gpt-5.4`).
1512
+ * Provider-agnostic alias lookup for bare-id selectors. Returns the declaring
1513
+ * providers so callers can prefer their models when the replacement id exists
1514
+ * on unrelated providers too (e.g. a retired Cursor tier id must not resolve
1515
+ * to `openai/gpt-5.4`).
1274
1516
  */
1275
1517
  export function resolveBareVariantAlias(modelId: string): BareVariantAliasHit | undefined {
1276
1518
  const normalized = modelId.trim().toLowerCase();
1277
- for (const provider in VARIANT_COLLAPSE_TABLES) {
1278
- const table = VARIANT_COLLAPSE_TABLES[provider] as VariantCollapseTable;
1279
- const hit = getAliasIndex(table).forward.get(normalized);
1519
+ const providerIds = new Set<string>();
1520
+ for (const provider in VARIANT_COLLAPSE_TABLES) providerIds.add(provider);
1521
+ for (const provider of dynamicAliasIndexes.keys()) providerIds.add(provider);
1522
+ for (const provider of providerIds) {
1523
+ const hit = resolveRegisteredVariantAlias(provider, normalized);
1280
1524
  if (hit === undefined) continue;
1281
1525
  const providers: Provider[] = [];
1282
- for (const candidate in VARIANT_COLLAPSE_TABLES) {
1283
- // Match by resolved alias target, not table identity: the CCA providers
1284
- // now hold distinct table objects that still share these aliases.
1285
- if (
1286
- getAliasIndex(VARIANT_COLLAPSE_TABLES[candidate] as VariantCollapseTable).forward.get(normalized) === hit
1287
- ) {
1526
+ for (const candidate of providerIds) {
1527
+ if (resolveRegisteredVariantAlias(candidate, normalized) === hit) {
1288
1528
  providers.push(candidate);
1289
1529
  }
1290
1530
  }
@@ -1295,14 +1535,18 @@ export function resolveBareVariantAlias(modelId: string): BareVariantAliasHit |
1295
1535
 
1296
1536
  /**
1297
1537
  * Reverse alias lookup: the retired ids that resolve to `modelId` for
1298
- * `provider` via the hand table. Used to re-key config keyed by raw member
1299
- * ids (models.yml `modelOverrides`, suppressed selectors) onto the collapsed
1300
- * model. Empty for providers without a table.
1538
+ * `provider` via hand-table or registered live aliases. Used to re-key config
1539
+ * keyed by raw member ids (models.yml `modelOverrides`, suppressed selectors)
1540
+ * onto the collapsed model.
1301
1541
  */
1302
1542
  export function getVariantAliasSources(provider: Provider, modelId: string): readonly string[] {
1303
- const table = VARIANT_COLLAPSE_TABLES[provider] ?? VARIANT_COLLAPSE_TABLES[provider.toLowerCase()];
1304
- if (!table) return [];
1305
- return getAliasIndex(table).reverse.get(modelId) ?? [];
1543
+ const providerId = provider.toLowerCase();
1544
+ const table = VARIANT_COLLAPSE_TABLES[provider] ?? VARIANT_COLLAPSE_TABLES[providerId];
1545
+ const staticSources = table ? getAliasIndex(table).reverse.get(modelId) : undefined;
1546
+ const dynamicSources = dynamicAliasIndexes.get(providerId)?.reverse.get(modelId);
1547
+ if (!staticSources) return dynamicSources ?? [];
1548
+ if (!dynamicSources) return staticSources;
1549
+ return [...new Set([...staticSources, ...dynamicSources])];
1306
1550
  }
1307
1551
 
1308
1552
  function maxOrNull(values: ReadonlyArray<number | null>): number | null {
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Identification of provider-side image fetchers.
3
+ *
4
+ * A request may carry an image either inline (base64) or as a URL. With a URL,
5
+ * the provider's backend performs its own server-side GET against that URL, so
6
+ * a local blob server sees an inbound request from vendor infrastructure rather
7
+ * than from the client. This module names those fetchers, letting a blob server
8
+ * attribute an inbound GET to the vendor that issued it — which is how a caller
9
+ * learns where a request actually landed when a router sits between them and
10
+ * the model.
11
+ *
12
+ * NOT an authentication mechanism. Every value here is a request header chosen
13
+ * by the caller and is trivially forged. Authorize blob reads with an
14
+ * unguessable URL (single-use capability token, short TTL) and treat a fetcher
15
+ * match as attribution/telemetry only.
16
+ *
17
+ * Each entry was captured from a live fetch triggered by handing that vendor's
18
+ * API a URL-sourced image.
19
+ */
20
+
21
+ /** Vendor whose infrastructure performed an inbound fetch. */
22
+ export type ImageFetcherVendor = "openai" | "anthropic" | "xai" | "google";
23
+
24
+ /** Registry key for a known fetcher. */
25
+ export type ImageFetcherId =
26
+ | "openai-file-downloader"
27
+ | "anthropic-claude-user"
28
+ | "anthropic-claude-user-preview"
29
+ | "xai-image-api-fetch"
30
+ | "google";
31
+
32
+ /** Request signature of one provider-side fetcher. */
33
+ export interface ImageFetcherIdentity {
34
+ vendor: ImageFetcherVendor;
35
+ /** Human-readable name for logs and UI. */
36
+ label: string;
37
+ /**
38
+ * `User-Agent` contract: an exact string for fetchers that send a fixed
39
+ * value, or a pattern for those embedding a client version.
40
+ */
41
+ userAgent: string | RegExp;
42
+ /**
43
+ * Vendor-proprietary headers observed alongside the agent, used to
44
+ * corroborate a `User-Agent` claim. Generic infrastructure headers
45
+ * (`traceparent`, `x-cloud-trace-context`) are deliberately excluded: they
46
+ * are emitted by unrelated infrastructure and corroborate nothing. Empty
47
+ * means the agent string is the only available signal, so
48
+ * {@link ImageFetcherMatch.corroborated} can never be true for that entry.
49
+ */
50
+ markerHeaders: readonly string[];
51
+ /** API surface the capture came from. */
52
+ observedVia: string;
53
+ /** Operational caveats a blob server should account for. */
54
+ note?: string;
55
+ }
56
+
57
+ /**
58
+ * Known provider-side fetchers.
59
+ *
60
+ * Agent contracts do not overlap — exact strings never collide with the
61
+ * versioned patterns — so lookup order carries no meaning.
62
+ */
63
+ export const IMAGE_FETCHERS: Readonly<Record<ImageFetcherId, ImageFetcherIdentity>> = {
64
+ "openai-file-downloader": {
65
+ vendor: "openai",
66
+ label: "OpenAI File Downloader",
67
+ userAgent: "OpenAI File Downloader",
68
+ markerHeaders: ["openai-internal-smokescreener"],
69
+ observedVia: "chatgpt.com/backend-api/codex/responses, input_image.image_url",
70
+ note: "Issues two near-simultaneous GETs per image; a blob server must treat a duplicate hit as expected rather than as replay.",
71
+ },
72
+ "anthropic-claude-user": {
73
+ vendor: "anthropic",
74
+ label: "Claude image fetcher",
75
+ userAgent: "Claude-User",
76
+ markerHeaders: [],
77
+ observedVia: "api.anthropic.com/v1/messages, image.source.type=url",
78
+ note: "Sends only generic trace headers, so the bare agent string is the sole signal. Distinct from the versioned Claude-User/<version> agent used for links appearing in conversation text.",
79
+ },
80
+ "anthropic-claude-user-preview": {
81
+ vendor: "anthropic",
82
+ label: "Claude link fetcher",
83
+ userAgent: /\bClaude-User\/\d+(?:\.\d+)*\b/,
84
+ markerHeaders: [],
85
+ observedVia:
86
+ "unsolicited fetch after a URL appeared in assistant output; never observed serving an image request",
87
+ note: "Not an image fetcher. Listed so a blob server can separate it from the image path instead of counting it as a provider image fetch.",
88
+ },
89
+ "xai-image-api-fetch": {
90
+ vendor: "xai",
91
+ label: "xAI image API fetch",
92
+ userAgent: /^XaiImageApiFetch\/\d+(?:\.\d+)*\s/,
93
+ markerHeaders: ["x-xaifetchid"],
94
+ observedVia: "api.x.ai/v1/responses, input_image.image_url",
95
+ note: "Sends an image-only `accept` allowlist and rejects any other content type before the model sees the response.",
96
+ },
97
+ google: {
98
+ vendor: "google",
99
+ label: "Google",
100
+ userAgent: "Google",
101
+ markerHeaders: [],
102
+ observedVia: "cloudcode-pa.googleapis.com v1internal:streamGenerateContent, fileData.fileUri",
103
+ note: "Weakest signal of the set: the agent string is a bare vendor name with no version and no proprietary header.",
104
+ },
105
+ };
106
+
107
+ /** Attribution outcome for one inbound request. */
108
+ export interface ImageFetcherMatch {
109
+ id: ImageFetcherId;
110
+ identity: ImageFetcherIdentity;
111
+ /**
112
+ * Whether every proprietary marker header for the matched identity is
113
+ * present. Always false for identities declaring no markers — absence of
114
+ * corroboration is not evidence against the match.
115
+ */
116
+ corroborated: boolean;
117
+ }
118
+
119
+ /** Inbound header bag, as exposed by either `fetch` or a Node-style server. */
120
+ export type InboundHeaders = Headers | Readonly<Record<string, string | readonly string[] | undefined>>;
121
+
122
+ function headerValue(headers: InboundHeaders, name: string): string | undefined {
123
+ if (headers instanceof Headers) return headers.get(name) ?? undefined;
124
+ const direct = headers[name];
125
+ if (direct !== undefined) return Array.isArray(direct) ? direct[0] : (direct as string);
126
+ for (const key in headers) {
127
+ if (key.toLowerCase() !== name) continue;
128
+ const value = headers[key];
129
+ return Array.isArray(value) ? value[0] : (value as string | undefined);
130
+ }
131
+ return undefined;
132
+ }
133
+
134
+ /**
135
+ * Attribute an inbound blob request to a known provider-side fetcher, or
136
+ * `null` when the agent matches none.
137
+ *
138
+ * Matches on `User-Agent` alone and reports marker-header corroboration
139
+ * separately; callers MUST NOT treat either as proof of origin. Gate access on
140
+ * the unguessable URL.
141
+ */
142
+ export function identifyImageFetcher(headers: InboundHeaders): ImageFetcherMatch | null {
143
+ const agent = headerValue(headers, "user-agent");
144
+ if (!agent) return null;
145
+ for (const id in IMAGE_FETCHERS) {
146
+ const identity = IMAGE_FETCHERS[id as ImageFetcherId];
147
+ const { userAgent } = identity;
148
+ const matched = typeof userAgent === "string" ? agent === userAgent : userAgent.test(agent);
149
+ if (!matched) continue;
150
+ return {
151
+ id: id as ImageFetcherId,
152
+ identity,
153
+ corroborated:
154
+ identity.markerHeaders.length > 0 &&
155
+ identity.markerHeaders.every(header => headerValue(headers, header) !== undefined),
156
+ };
157
+ }
158
+ return null;
159
+ }