cito-mcp 0.3.21 → 0.4.1

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.
@@ -62,6 +62,138 @@ function numOrNull(v) {
62
62
  return Number(v);
63
63
  return null;
64
64
  }
65
+ /**
66
+ * One set of a tennis score string ("6-4", "7-6(6)", "6-4, 3-6") as games won.
67
+ *
68
+ * A tiebreak is parenthesised after the games ("7-6(6)"), so the regex stops at
69
+ * the games. Anything else returns null rather than a guess: retirement rows
70
+ * carry "6-3 2-1 RET", and reading "RET" as games would invent a set.
71
+ */
72
+ export function parseTennisSetToken(token) {
73
+ const m = /^\s*(\d{1,2})\s*-\s*(\d{1,2})/.exec(token);
74
+ if (!m)
75
+ return null;
76
+ return { p1: Number(m[1]), p2: Number(m[2]) };
77
+ }
78
+ /**
79
+ * Sets won by one side, counted from the match-level game score
80
+ * ("4-6 6-3 6-3 7-5" → 3 for side 1, 1 for side 2).
81
+ *
82
+ * `score` orders every token as player1-player2 on this feed, which is verified
83
+ * against the same row's sets[] (set 1: score token "4-6", player1_games 4,
84
+ * player2_games 6). Ties are impossible in a completed set, so a token where
85
+ * neither side is ahead contributes to neither total.
86
+ */
87
+ export function tennisSetsWonFromScore(score, side) {
88
+ if (!score)
89
+ return null;
90
+ let won = 0;
91
+ let parsed = 0;
92
+ for (const token of score.split(/[\s,]+/)) {
93
+ if (!token)
94
+ continue;
95
+ const set = parseTennisSetToken(token);
96
+ if (!set)
97
+ continue;
98
+ parsed += 1;
99
+ const mine = side === 1 ? set.p1 : set.p2;
100
+ const theirs = side === 1 ? set.p2 : set.p1;
101
+ if (mine > theirs)
102
+ won += 1;
103
+ }
104
+ return parsed > 0 ? won : null;
105
+ }
106
+ /**
107
+ * Sets won by one side, counted from the structured sets[] array.
108
+ *
109
+ * player1_games/player2_games are the ONLY side-correct pair here. The sibling
110
+ * winner_games/loser_games are oriented to the match winner, not to side 1 —
111
+ * on s365_2026_4849786 set 1 is `winner_games: 4, loser_games: 6` while side 1
112
+ * (the winner, Shelton) actually lost that set 4-6. Reading those would have
113
+ * produced a confidently wrong 1-3 scoreline.
114
+ */
115
+ export function tennisSetsWonFromSets(sets, side) {
116
+ if (!Array.isArray(sets) || sets.length === 0)
117
+ return null;
118
+ let won = 0;
119
+ let parsed = 0;
120
+ for (const raw of sets) {
121
+ const s = asRecord(raw);
122
+ if (!s)
123
+ continue;
124
+ const p1 = numOrNull(s.player1_games);
125
+ const p2 = numOrNull(s.player2_games);
126
+ if (p1 === null || p2 === null)
127
+ continue;
128
+ parsed += 1;
129
+ const mine = side === 1 ? p1 : p2;
130
+ const theirs = side === 1 ? p2 : p1;
131
+ if (mine > theirs)
132
+ won += 1;
133
+ }
134
+ return parsed > 0 ? won : null;
135
+ }
136
+ /**
137
+ * Whether a set row is finished.
138
+ *
139
+ * The live route states `is_completed`; the archive route omits the key
140
+ * entirely, and reading only the flag marked all four sets of a COMPLETED US
141
+ * Open semi-final `completed: false`. When the flag is absent the games decide:
142
+ * a set is over at 6-x with a two-game margin, or at 7-6/7-5.
143
+ */
144
+ export function tennisSetCompleted(sr) {
145
+ if (typeof sr.is_completed === 'boolean')
146
+ return sr.is_completed;
147
+ const p1 = numOrNull(sr.player1_games);
148
+ const p2 = numOrNull(sr.player2_games);
149
+ if (p1 === null || p2 === null)
150
+ return false;
151
+ const hi = Math.max(p1, p2);
152
+ const lo = Math.min(p1, p2);
153
+ return (hi >= 6 && hi - lo >= 2) || (hi === 7 && lo === 6);
154
+ }
155
+ /**
156
+ * Sets won by one side of a tennis match, from whichever spelling the row uses.
157
+ *
158
+ * Four sources exist because three different endpoints describe the same match:
159
+ * 1. `sets_won` — the live route puts it on each player object
160
+ * 2. `winner_sets_won`/`loser_sets_won` — the archive route's own totals
161
+ * 3. `sets[]` — per-set games, present on both
162
+ * 4. the `score` string — "4-6 6-3 6-3 7-5", present on both
163
+ *
164
+ * The archive route (`/tennis/matches/{id}`) carries NO sets_won on its player
165
+ * objects at all, which is why a completed US Open semi-final rendered as
166
+ * "Ben Shelton - : - Frances Tiafoe" while the same response held the real
167
+ * score in `score` and in `sets[]`. Reading only source 1 is the bug; the order
168
+ * above is authority, not preference — a server-computed total beats a count we
169
+ * derive, and the structured array beats re-parsing a display string.
170
+ */
171
+ export function tennisSetsWon(opts) {
172
+ const explicit = numOrNull(opts.sideSetsWon);
173
+ if (explicit !== null)
174
+ return explicit;
175
+ // winner_sets_won / loser_sets_won belong to a side, and which side is known
176
+ // either from the row's own is_winner flag or by matching ids. Without one of
177
+ // those signals the pair is unusable and the structured sets below are safer
178
+ // than a coin flip.
179
+ const wsw = numOrNull(opts.winnerSetsWon);
180
+ const lsw = numOrNull(opts.loserSetsWon);
181
+ if (wsw !== null && lsw !== null) {
182
+ let isWinner = opts.isWinner;
183
+ if (isWinner === null || isWinner === undefined) {
184
+ if (opts.winnerId && opts.sideId)
185
+ isWinner = opts.winnerId === opts.sideId;
186
+ }
187
+ if (isWinner === true)
188
+ return wsw;
189
+ if (isWinner === false)
190
+ return lsw;
191
+ }
192
+ const fromSets = tennisSetsWonFromSets(Array.isArray(opts.sets) ? opts.sets : [], opts.side);
193
+ if (fromSets !== null)
194
+ return fromSets;
195
+ return tennisSetsWonFromScore(opts.score ?? undefined, opts.side);
196
+ }
65
197
  /**
66
198
  * Collect image URLs from a corner/team row and its nested profile. Returns
67
199
  * undefined when the upstream carried none, so lean rows stay lean; when any
@@ -82,9 +214,11 @@ function imagesFrom(game, ...sources) {
82
214
  return null;
83
215
  };
84
216
  const images = {
85
- headshotUrl: pick('headshotUrl', 'headshot'),
217
+ // portrait_url is the tennis feed's only likeness (see identityFrom in
218
+ // player.ts); without it a tennis side on every board carried no image.
219
+ headshotUrl: pick('headshotUrl', 'headshot', 'portrait_url'),
86
220
  bodyImageUrl: pick('bodyImageUrl', 'fullBodyImageUrl'),
87
- imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo'),
221
+ imageUrl: pick('imageUrl', 'image', 'photoUrl', 'logoUrl', 'logo', 'portrait_url'),
88
222
  proxiedImageUrl: pick('proxiedImageUrl', 'proxiedHeadshotUrl'),
89
223
  };
90
224
  if (!images.proxiedImageUrl) {
@@ -125,7 +259,9 @@ function sideExtras(o, game) {
125
259
  const rank = pickString(o.rankText, o.rank, profile?.rankText);
126
260
  const championStatus = pickString(o.championStatus, profile?.championStatus);
127
261
  const division = pickString(profile?.division, o.division, o.weightClass);
128
- const country = pickString(o.country, profile?.country);
262
+ // `ioc` is the tennis feed's country code ("ESP"); it was not read, so every
263
+ // tennis side on every board came back country-less.
264
+ const country = pickString(o.country, profile?.country, o.ioc);
129
265
  const flag = pickString(o.flag, profile?.flag);
130
266
  const outcome = pickString(o.outcome);
131
267
  return {
@@ -238,11 +374,38 @@ export function normalizeMatch(game, row, forcedStatus) {
238
374
  const p2obj = asRecord(r.player2);
239
375
  const p1 = pickString(p1obj?.name, r.player1_name);
240
376
  const p2 = pickString(p2obj?.name, r.player2_name);
377
+ // winner_sets_won/loser_sets_won and score/sets are match-level; only the
378
+ // side flags differ, so they are shared by both branches below.
379
+ const scoreText = pickString(r.score, r.score_raw) ?? null;
241
380
  if (p1 || p2) {
242
- const score1 = typeof p1obj?.sets_won === 'number' ? p1obj.sets_won : undefined;
243
- const score2 = typeof p2obj?.sets_won === 'number' ? p2obj.sets_won : undefined;
244
- team1 = sideFrom(p1, pickString(p1obj?.id, r.player1_id), undefined, score1);
245
- team2 = sideFrom(p2, pickString(p2obj?.id, r.player2_id), undefined, score2);
381
+ const p1Id = pickString(p1obj?.id, r.player1_id) ?? null;
382
+ const p2Id = pickString(p2obj?.id, r.player2_id) ?? null;
383
+ const winnerId = pickString(r.winner_id) ?? null;
384
+ const boolOrNull = (v) => (typeof v === 'boolean' ? v : null);
385
+ const score1 = tennisSetsWon({
386
+ sideSetsWon: p1obj?.sets_won,
387
+ winnerSetsWon: r.winner_sets_won,
388
+ loserSetsWon: r.loser_sets_won,
389
+ isWinner: boolOrNull(p1obj?.is_winner),
390
+ winnerId,
391
+ sideId: p1Id,
392
+ sets: r.sets,
393
+ score: scoreText,
394
+ side: 1,
395
+ });
396
+ const score2 = tennisSetsWon({
397
+ sideSetsWon: p2obj?.sets_won,
398
+ winnerSetsWon: r.winner_sets_won,
399
+ loserSetsWon: r.loser_sets_won,
400
+ isWinner: boolOrNull(p2obj?.is_winner),
401
+ winnerId,
402
+ sideId: p2Id,
403
+ sets: r.sets,
404
+ score: scoreText,
405
+ side: 2,
406
+ });
407
+ team1 = sideFrom(p1, p1Id ?? undefined, undefined, score1, sideExtras(p1obj ?? {}, game));
408
+ team2 = sideFrom(p2, p2Id ?? undefined, undefined, score2, sideExtras(p2obj ?? {}, game));
246
409
  }
247
410
  else {
248
411
  // Archive detail nests winner/loser objects with real names; list rows
@@ -254,8 +417,25 @@ export function normalizeMatch(game, row, forcedStatus) {
254
417
  const wId = pickString(wObj?.id, r.winner_id);
255
418
  const lId = pickString(lObj?.id, r.loser_id);
256
419
  if (wName || lName || wId || lId) {
257
- team1 = sideFrom(wName ?? wId, wId, undefined, undefined);
258
- team2 = sideFrom(lName ?? lId, lId, undefined, undefined);
420
+ // This shape IS winner-first, so side 1 is the winner by construction.
421
+ const wScore = tennisSetsWon({
422
+ winnerSetsWon: r.winner_sets_won,
423
+ loserSetsWon: r.loser_sets_won,
424
+ isWinner: true,
425
+ sets: r.sets,
426
+ score: scoreText,
427
+ side: 1,
428
+ });
429
+ const lScore = tennisSetsWon({
430
+ winnerSetsWon: r.winner_sets_won,
431
+ loserSetsWon: r.loser_sets_won,
432
+ isWinner: false,
433
+ sets: r.sets,
434
+ score: scoreText,
435
+ side: 2,
436
+ });
437
+ team1 = sideFrom(wName ?? wId, wId, undefined, wScore);
438
+ team2 = sideFrom(lName ?? lId, lId, undefined, lScore);
259
439
  }
260
440
  }
261
441
  }
@@ -440,9 +620,21 @@ export function normalizeMatch(game, row, forcedStatus) {
440
620
  ? undefined
441
621
  : explicitRaw;
442
622
  const explicitIsPlaceholder = explicit != null && /^\?\s*vs\s*\?$/i.test(explicit.trim());
623
+ // Tennis rows carry no `label`, but the live route does set `name` — and that
624
+ // name is the TOURNAMENT ("Challenger, Seville"), not the matchup. Preferring
625
+ // it made live_matches label a tennis row by its event while
626
+ // upcoming_schedule, off a route with no such field, labelled the same
627
+ // fixture "Max Alcala Gurri vs Dusan Lajovic". Two boards, one match, two
628
+ // labels. A "matchup" that is really an event name is not a matchup.
629
+ const explicitIsEventName = explicit != null &&
630
+ game === 'tennis' &&
631
+ Boolean(team1?.name || team2?.name) &&
632
+ [eventName, eventSlug, r.tournament_name, r.tournament]
633
+ .filter((v) => typeof v === 'string' && v.length > 0)
634
+ .some((v) => v.trim().toLowerCase() === explicit.trim().toLowerCase());
443
635
  const weightOrClass = pickString(r.weightClass, r.division, r.weight_class, r.boutClass);
444
636
  let label;
445
- if (explicit && !explicitIsPlaceholder) {
637
+ if (explicit && !explicitIsPlaceholder && !explicitIsEventName) {
446
638
  label = explicit;
447
639
  }
448
640
  else if (team1?.name || team2?.name) {
@@ -483,12 +675,19 @@ export function normalizeMatch(game, row, forcedStatus) {
483
675
  if (game === 'tennis' && Array.isArray(r.sets) && r.sets.length) {
484
676
  tennisSets = r.sets.map((s) => {
485
677
  const sr = asRecord(s) ?? {};
678
+ const p1g = numOrNull(sr.player1_games);
679
+ const p2g = numOrNull(sr.player2_games);
486
680
  return {
487
- setNumber: numOrNull(sr.set_num),
488
- player1Games: numOrNull(sr.player1_games),
489
- player2Games: numOrNull(sr.player2_games),
681
+ // The live route spells it set_num; the archive route set_number. Both
682
+ // are emitted by the same API for the same set.
683
+ setNumber: numOrNull(sr.set_num) ?? numOrNull(sr.set_number),
684
+ score: pickString(sr.score) ?? (p1g !== null && p2g !== null ? `${p1g}-${p2g}` : null),
685
+ player1Games: p1g,
686
+ player2Games: p2g,
490
687
  tiebreak: sr.tiebreak == null ? null : (pickString(sr.tiebreak) ?? String(sr.tiebreak)),
491
- completed: sr.is_completed === true,
688
+ // is_completed is a live-route field only; the archive route omits it
689
+ // and every set of a finished match read as completed:false.
690
+ completed: tennisSetCompleted(sr),
492
691
  };
493
692
  });
494
693
  tennisGameScore = pickString(r.game_score) ?? null;
@@ -624,7 +823,14 @@ export function rankScore(query, name, id, slug, ...extra) {
624
823
  best = Math.max(best, 92); // "jon jones" vs "Jon Jones"
625
824
  }
626
825
  else {
627
- const hits = qt.filter((t) => ct.some((x) => x.includes(t) || t.includes(x))).length;
826
+ // A hit is the candidate token containing the query token, or the query
827
+ // token containing a candidate token OF REAL LENGTH. The reverse
828
+ // direction used to accept any length, so the single letter "e" in
829
+ // "E Skinner" counted as a hit against the query "sinner" ("sinner"
830
+ // contains "e") and scored 32 — which is how eight strangers padded a
831
+ // search for one player. A one- or two-character fragment carries no
832
+ // identity, so it no longer counts.
833
+ const hits = qt.filter((t) => ct.some((x) => x.includes(t) || (t.includes(x) && x.length >= 3))).length;
628
834
  if (hits === qt.length && qt.length > 0)
629
835
  best = Math.max(best, 75);
630
836
  else if (hits)
@@ -0,0 +1,295 @@
1
+ /**
2
+ * tennis_odds — the betting-odds surface for tennis (TENNIS-24).
3
+ *
4
+ * WHY THIS EXISTS
5
+ * The tennis API serves three odds routes, and NONE of them had a tool. Odds are
6
+ * a headline feature of the paid tiers and one of the most common reasons to buy
7
+ * a sports API, so a builder had to discover /tennis/odds/* through call_api or
8
+ * the docs. This closes that gap.
9
+ *
10
+ * Coverage is genuinely partial upstream, and the tool says so rather than
11
+ * returning an empty success: /tennis/odds/{id} answers with
12
+ * `coverage: { odds: false }` and an empty bookmakers[] for any match the feed
13
+ * has no prices for (verified against atp_2026_560, which returned exactly
14
+ * that). An agent that cannot tell "no odds exist for this match" from "the call
15
+ * failed" will invent a price, so the response distinguishes the two.
16
+ */
17
+ import { asRecord, clampInt, fetchJson, gameNotIncludedHint, pickString, } from '../client.js';
18
+ import { errorEnvelope, mapHttpToCode, newRequestId, successEnvelope, } from '../envelope.js';
19
+ import { gameSchema, isPrimaryGame, limitSchema, parseGame, stringSchema, } from './types.js';
20
+ /** A bookmaker row: { key, title, last_update, markets: [{ key, outcomes: [{ name, price }] }] }. */
21
+ function normalizeBookmaker(row) {
22
+ const r = asRecord(row) ?? {};
23
+ const markets = (Array.isArray(r.markets) ? r.markets : []).map((mk) => {
24
+ const m = asRecord(mk) ?? {};
25
+ const outcomes = (Array.isArray(m.outcomes) ? m.outcomes : []).map((o) => {
26
+ const oc = asRecord(o) ?? {};
27
+ return {
28
+ name: pickString(oc.name) ?? null,
29
+ // Decimal odds as the feed supplies them; never recomputed or implied.
30
+ price: typeof oc.price === 'number' && Number.isFinite(oc.price) ? oc.price : null,
31
+ point: typeof oc.point === 'number' && Number.isFinite(oc.point) ? oc.point : null,
32
+ };
33
+ });
34
+ return {
35
+ market: pickString(m.key) ?? null,
36
+ outcomes,
37
+ // Implied probability from the prices actually returned, so a caller does
38
+ // not have to redo the arithmetic. Null when any price is missing.
39
+ impliedProbabilities: (() => {
40
+ const prices = outcomes.map((o) => o.price).filter((p) => p !== null && p > 1);
41
+ if (prices.length !== outcomes.length || prices.length === 0)
42
+ return null;
43
+ const inv = prices.map((p) => 1 / p);
44
+ const sum = inv.reduce((a, b) => a + b, 0);
45
+ return inv.map((v) => Number((v / sum).toFixed(4)));
46
+ })(),
47
+ };
48
+ });
49
+ return {
50
+ key: pickString(r.key) ?? null,
51
+ title: pickString(r.title) ?? null,
52
+ lastUpdate: pickString(r.last_update) ?? null,
53
+ markets,
54
+ };
55
+ }
56
+ /**
57
+ * The one tennis-odds projection, shared by `tennis_odds` and `match_details`.
58
+ *
59
+ * Both surfaces read the same REST payload, and before this the two disagreed:
60
+ * `tennis_odds scope=match` served FanDuel 1.105 / Matchbook 1.13 with implied
61
+ * probabilities while `match_details sections:["odds"]` answered
62
+ * NOT_IMPLEMENTED "UFC only today" for the same match id. A caller could not
63
+ * tell whether tennis odds existed. One shaper, one answer.
64
+ */
65
+ export function summarizeTennisOdds(data) {
66
+ const root = asRecord(data) ?? {};
67
+ // fetchJson hands back the whole REST envelope; the unit tests feed the inner
68
+ // object directly. Accept both.
69
+ const payload = asRecord(root.data) ?? root;
70
+ const coverage = asRecord(payload.coverage) ?? {};
71
+ const bookmakers = (Array.isArray(payload.bookmakers) ? payload.bookmakers : []).map(normalizeBookmaker);
72
+ const hasOdds = coverage.odds === true || bookmakers.length > 0;
73
+ return {
74
+ matchId: pickString(payload.match_id) ?? null,
75
+ oddsFormat: pickString(payload.odds_format) ?? 'decimal',
76
+ commenceTime: pickString(payload.commence_time) ?? null,
77
+ /** Upstream says the feed has since moved on; the prices are still real. */
78
+ stale: payload.stale === true,
79
+ bookmakers,
80
+ // Explicit, because an empty bookmakers[] is otherwise indistinguishable
81
+ // from a failed scrape.
82
+ oddsAvailable: hasOdds,
83
+ note: hasOdds
84
+ ? null
85
+ : 'No bookmaker is currently quoting this match. This is an upstream coverage gap, not an error; retry closer to the start time.',
86
+ fullBookPath: '/tennis/odds/{matchId}',
87
+ };
88
+ }
89
+ function parseTennis(args, tool) {
90
+ const gameParse = parseGame(args.game, { allowAll: false, required: true });
91
+ if (gameParse.error || !gameParse.game || !isPrimaryGame(gameParse.game)) {
92
+ return { error: gameParse.error ?? 'game is required', game: null };
93
+ }
94
+ const game = gameParse.game;
95
+ if (game !== 'tennis') {
96
+ return {
97
+ error: `${tool} is tennis-only (odds for '${game}' is not served by this endpoint)`,
98
+ game,
99
+ };
100
+ }
101
+ return { game };
102
+ }
103
+ function requireTennisId(args, tool) {
104
+ const id = typeof args.matchId === 'string' ? args.matchId.trim() : '';
105
+ if (!id)
106
+ return { error: `${tool} requires matchId` };
107
+ return { matchId: id };
108
+ }
109
+ export const tennisOdds = {
110
+ name: 'tennis_odds',
111
+ description: `Tennis betting odds: upcoming matches with prices, plus pre-match and in-play odds for one match.
112
+
113
+ Modes (pick with 'scope'):
114
+ - upcoming (default): matches with odds available, each with the bookmakers quoting it.
115
+ - match: pre-match odds for one match_id.
116
+ - live: in-play odds for one match_id.
117
+
118
+ When to use:
119
+ - "What are the odds on X vs Y?"; pre-match prices; in-play prices; which matches have odds today.
120
+
121
+ Prefer over: call_api for /tennis/odds/*; match_preview (no prices).
122
+
123
+ Do not use when: the match result → match_details; rankings → standings.
124
+
125
+ Tennis-only. Odds are decimal. Coverage is partial upstream: a match with no
126
+ priced bookmaker returns coverage.odds=false and an empty bookmakers[] rather
127
+ than invented numbers, and this tool surfaces that distinction explicitly.
128
+
129
+ Parallel-safe: yes. Upstream cost: 1.`,
130
+ inputSchema: {
131
+ type: 'object',
132
+ additionalProperties: false,
133
+ required: ['game'],
134
+ properties: {
135
+ game: gameSchema({ allowAll: false, required: true }),
136
+ scope: stringSchema('upcoming (default) | match | live.', 'upcoming'),
137
+ matchId: stringSchema('Match id for scope=match or scope=live, e.g. "s365_2026_4853596".', 's365_2026_4853596'),
138
+ limit: limitSchema({ default: 20, max: 50, description: 'Rows for scope=upcoming (default 20, max 50).' }),
139
+ },
140
+ },
141
+ handler: async (args, ctx) => {
142
+ const started = Date.now();
143
+ const requestId = newRequestId();
144
+ const parsed = parseTennis(args, 'tennis_odds');
145
+ if (parsed.error) {
146
+ return errorEnvelope({
147
+ code: parsed.error.includes('unsupported') ? 'UNSUPPORTED_GAME' : 'VALIDATION',
148
+ message: parsed.error,
149
+ game: parsed.game,
150
+ source: 'tennis_odds',
151
+ requestId,
152
+ tookMs: Date.now() - started,
153
+ });
154
+ }
155
+ const game = parsed.game;
156
+ const scopeRaw = typeof args.scope === 'string' ? args.scope.trim().toLowerCase() : 'upcoming';
157
+ if (!['upcoming', 'match', 'live'].includes(scopeRaw)) {
158
+ return errorEnvelope({
159
+ code: 'VALIDATION',
160
+ message: `scope must be upcoming, match, or live (got '${args.scope}')`,
161
+ game,
162
+ source: 'tennis_odds',
163
+ requestId,
164
+ tookMs: Date.now() - started,
165
+ recover: ['Use scope=upcoming for matches with odds', 'Use scope=match or scope=live with a matchId'],
166
+ });
167
+ }
168
+ const matchId = typeof args.matchId === 'string' ? args.matchId.trim() : '';
169
+ if (scopeRaw !== 'upcoming' && !matchId) {
170
+ return errorEnvelope({
171
+ code: 'VALIDATION',
172
+ message: `matchId is required when scope=${scopeRaw}`,
173
+ game,
174
+ source: 'tennis_odds',
175
+ requestId,
176
+ tookMs: Date.now() - started,
177
+ recover: [`Pass matchId, or use scope=upcoming with no id`],
178
+ });
179
+ }
180
+ const limit = clampInt(args.limit, 20, 1, 50);
181
+ if (scopeRaw === 'upcoming') {
182
+ const res = await fetchJson(ctx, '/tennis/odds/upcoming', { query: { limit } });
183
+ if (!res.ok) {
184
+ return errorEnvelope({
185
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
186
+ message: `Upcoming tennis odds failed (HTTP ${res.status})`,
187
+ game,
188
+ source: 'tennis_odds',
189
+ requestId,
190
+ tookMs: Date.now() - started,
191
+ upstreamCalls: 1,
192
+ httpStatus: res.status,
193
+ rateLimit: res.headers,
194
+ });
195
+ }
196
+ const root = asRecord(res.data) ?? {};
197
+ const body = asRecord(root.data) ?? root;
198
+ const items = (Array.isArray(body.items) ? body.items : []).map((row) => {
199
+ const r = asRecord(row) ?? {};
200
+ const home = asRecord(r.home) ?? {};
201
+ const away = asRecord(r.away) ?? {};
202
+ const homeId = pickString(home.player_id, home.id) ?? null;
203
+ const awayId = pickString(away.player_id, away.id) ?? null;
204
+ const matchId = pickString(r.match_id, r.id) ?? null;
205
+ const oddsEventId = pickString(r.odds_event_id) ?? null;
206
+ return {
207
+ matchId,
208
+ oddsEventId,
209
+ tournament: pickString(r.tournament) ?? null,
210
+ commenceTime: pickString(r.commence_time, r.starts_at) ?? null,
211
+ player1: { name: pickString(home.name) ?? null, id: homeId },
212
+ player2: { name: pickString(away.name) ?? null, id: awayId },
213
+ bookmakers: Array.isArray(r.bookmakers) ? r.bookmakers.map((b) => pickString(b) ?? null) : [],
214
+ live: r.live === true,
215
+ /**
216
+ * The feed does not join its odds events to matches: every row of
217
+ * /tennis/odds/upcoming carries match_id:null (verified on all 94
218
+ * rows), so a caller could not go odds -> match and the two halves of
219
+ * a betting UI could not be stitched together. matchId itself is left
220
+ * exactly as the feed states it — null stays null, because inventing
221
+ * one would be worse — and the join key that DOES resolve is named
222
+ * alongside it.
223
+ */
224
+ joinKey: matchId
225
+ ? { kind: 'matchId', matchId }
226
+ : oddsEventId
227
+ ? { kind: 'oddsEventId', oddsEventId }
228
+ : null,
229
+ /** Player ids present on the row, which identify the fixture when matchId is null. */
230
+ playerIds: [homeId, awayId].filter((x) => Boolean(x)),
231
+ };
232
+ });
233
+ const total = typeof body.total === 'number' ? body.total : items.length;
234
+ return successEnvelope({
235
+ pagination: {
236
+ limit,
237
+ offset: 0,
238
+ total,
239
+ hasMore: body.has_next === true,
240
+ nextCursor: null,
241
+ prevCursor: null,
242
+ },
243
+ source: 'tennis_odds',
244
+ game,
245
+ requestId,
246
+ tookMs: Date.now() - started,
247
+ upstreamCalls: 1,
248
+ rateLimit: res.headers,
249
+ data: {
250
+ title: `Tennis odds — upcoming (${items.length} of ${total})`,
251
+ scope: scopeRaw,
252
+ oddsFormat: 'decimal',
253
+ items,
254
+ total,
255
+ },
256
+ });
257
+ }
258
+ const path = scopeRaw === 'match'
259
+ ? `/tennis/odds/${encodeURIComponent(matchId)}`
260
+ : `/tennis/odds/${encodeURIComponent(matchId)}/live`;
261
+ const res = await fetchJson(ctx, path, {});
262
+ if (!res.ok) {
263
+ return errorEnvelope({
264
+ code: mapHttpToCode(res.status, { gameNotIncluded: gameNotIncludedHint(res.data) }),
265
+ message: `Tennis ${scopeRaw} odds failed for '${matchId}' (HTTP ${res.status})`,
266
+ game,
267
+ source: 'tennis_odds',
268
+ requestId,
269
+ tookMs: Date.now() - started,
270
+ upstreamCalls: 1,
271
+ httpStatus: res.status,
272
+ rateLimit: res.headers,
273
+ recover: ['Check the match id with live_matches or match_details'],
274
+ });
275
+ }
276
+ const summary = summarizeTennisOdds(res.data);
277
+ return successEnvelope({
278
+ source: 'tennis_odds',
279
+ game,
280
+ requestId,
281
+ tookMs: Date.now() - started,
282
+ upstreamCalls: 1,
283
+ rateLimit: res.headers,
284
+ data: {
285
+ title: `Tennis ${scopeRaw} odds — ${matchId}`,
286
+ scope: scopeRaw,
287
+ // One projection, the same object match_details sections:["odds"]
288
+ // returns. See summarizeTennisOdds.
289
+ ...summary,
290
+ matchId: summary.matchId ?? matchId,
291
+ },
292
+ });
293
+ },
294
+ };
295
+ export const oddsTools = [tennisOdds];