catence 0.1.0 → 0.2.0-beta.10
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.
- package/README.md +239 -269
- package/config.example.json +32 -20
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +48 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +42 -0
- package/dist/core/runtime/catalog.d.ts +41 -0
- package/dist/core/runtime/catalog.js +121 -0
- package/dist/core/runtime/configuration.d.ts +1 -1
- package/dist/core/runtime/configuration.js +3 -3
- package/dist/core/runtime/secrets.d.ts +24 -0
- package/dist/core/runtime/secrets.js +68 -0
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/elt/application/management.d.ts +2 -0
- package/dist/elt/application/management.js +197 -18
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +4 -3
- package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
- package/dist/elt/ingestion/providers/intervals.js +4 -1
- package/dist/elt/ingestion/providers/strava/service.js +9 -2
- package/dist/elt/storage/database.d.ts +7 -2
- package/dist/elt/storage/database.js +81 -4
- package/dist/elt/storage/migrations.js +25 -1
- package/dist/interfaces/cli/commands.js +201 -46
- package/dist/interfaces/http/cli-options.d.ts +2 -2
- package/dist/interfaces/http/cli-options.js +10 -10
- package/dist/interfaces/http/server.d.ts +4 -1
- package/dist/interfaces/http/server.js +31 -4
- package/dist/interfaces/mcp/cli-options.d.ts +2 -2
- package/dist/interfaces/mcp/cli-options.js +14 -14
- package/dist/interfaces/mcp/main.js +12 -5
- package/dist/interfaces/mcp/server.d.ts +2 -2
- package/dist/interfaces/mcp/server.js +156 -98
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +4 -1
- package/docs/deployment.md +338 -0
- package/docs/docker-deployment.md +232 -0
- package/docs/llm-providers.md +467 -0
- package/package.json +6 -2
- package/python/catence/providers/garmin/cli.py +30 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/worker.py +132 -5
- package/scripts/discover-opencode-go.mjs +210 -0
- package/scripts/discover-opencode-zen.mjs +218 -0
|
@@ -6,11 +6,16 @@ import hashlib
|
|
|
6
6
|
import json
|
|
7
7
|
from typing import Any, Callable, Mapping
|
|
8
8
|
|
|
9
|
+
from .progress import ProgressReporter
|
|
9
10
|
from .registry import GARMIN_READ_METHODS, assert_read_only_registry
|
|
10
11
|
from .staging import StagingWriter
|
|
11
12
|
from .streams import SAMPLE_COLUMNS, activity_details_to_samples, activity_power_bests, fit_archive_to_samples, write_parquet
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
class WorkerInterrupted(Exception):
|
|
16
|
+
"""Raised between extraction steps when the run is asked to stop."""
|
|
17
|
+
|
|
18
|
+
|
|
14
19
|
def as_dict(value: Any) -> dict[str, Any]:
|
|
15
20
|
return value if isinstance(value, dict) else {"value": value}
|
|
16
21
|
|
|
@@ -80,11 +85,23 @@ def payload_date(payload: Mapping[str, Any]) -> str | None:
|
|
|
80
85
|
class GarminStagingWorker:
|
|
81
86
|
"""Extract Garmin data through the explicit read-only registry into JSONL/Parquet staging."""
|
|
82
87
|
|
|
83
|
-
def __init__(self, api: Any, writer: StagingWriter, data_dir: Path, known_activity_hashes: Mapping[str, str] | None = None) -> None:
|
|
88
|
+
def __init__(self, api: Any, writer: StagingWriter, data_dir: Path, known_activity_hashes: Mapping[str, str] | None = None, progress: ProgressReporter | None = None) -> None:
|
|
84
89
|
self.api = api
|
|
85
90
|
self.writer = writer
|
|
86
91
|
self.data_dir = data_dir
|
|
87
92
|
self.known_activity_hashes = dict(known_activity_hashes or {})
|
|
93
|
+
self.progress = progress
|
|
94
|
+
self._interrupted = False
|
|
95
|
+
|
|
96
|
+
def request_interrupt(self) -> None:
|
|
97
|
+
"""Ask the worker to stop at the next safe boundary."""
|
|
98
|
+
self._interrupted = True
|
|
99
|
+
|
|
100
|
+
def _check_interrupted(self) -> None:
|
|
101
|
+
if self._interrupted:
|
|
102
|
+
if self.progress is not None:
|
|
103
|
+
self.progress.finish("interrupted")
|
|
104
|
+
raise WorkerInterrupted("Sync run interrupted between extraction steps")
|
|
88
105
|
|
|
89
106
|
def sync(
|
|
90
107
|
self,
|
|
@@ -100,17 +117,26 @@ class GarminStagingWorker:
|
|
|
100
117
|
daily_end = daily_to_date or end
|
|
101
118
|
activity_end = activity_to_date or end
|
|
102
119
|
if include_non_historical:
|
|
120
|
+
self._check_interrupted()
|
|
103
121
|
self._singletons()
|
|
104
122
|
if daily_from_date and daily_from_date <= daily_end:
|
|
123
|
+
self._check_interrupted()
|
|
105
124
|
self._daily(daily_from_date, daily_end)
|
|
125
|
+
self._check_interrupted()
|
|
106
126
|
self._range(daily_from_date, daily_end)
|
|
127
|
+
self._check_interrupted()
|
|
107
128
|
self._cycling_ftp_history(daily_from_date, daily_end)
|
|
129
|
+
self._check_interrupted()
|
|
108
130
|
self._max_metrics_history(daily_from_date, daily_end)
|
|
131
|
+
self._check_interrupted()
|
|
109
132
|
self._hrv_history(daily_from_date, daily_end)
|
|
133
|
+
self._check_interrupted()
|
|
110
134
|
self._score_history(daily_from_date, daily_end)
|
|
111
135
|
if activity_from_date and activity_from_date <= activity_end:
|
|
136
|
+
self._check_interrupted()
|
|
112
137
|
self._activities(activity_from_date, activity_end)
|
|
113
138
|
if include_non_historical:
|
|
139
|
+
self._check_interrupted()
|
|
114
140
|
self._collections()
|
|
115
141
|
|
|
116
142
|
def _capture(self, endpoint: str, action: Callable[[], Any], remote_id_value: str | None = None, scope: dict[str, Any] | None = None) -> tuple[Any | None, str | None]:
|
|
@@ -162,10 +188,18 @@ class GarminStagingWorker:
|
|
|
162
188
|
"golf_user_stats": ("get_golf_user_stats", "golf_user_stat"),
|
|
163
189
|
"golf_club_stats": ("get_golf_club_stats", "golf_club_stat"),
|
|
164
190
|
}
|
|
165
|
-
|
|
191
|
+
if self.progress is not None:
|
|
192
|
+
self.progress.set_stage("singletons")
|
|
193
|
+
self.progress.advance(total=len(calls))
|
|
194
|
+
for index, (endpoint, (method, entity_type)) in enumerate(calls.items(), start=1):
|
|
195
|
+
self._check_interrupted()
|
|
196
|
+
if self.progress is not None:
|
|
197
|
+
self.progress.advance(step=endpoint)
|
|
166
198
|
payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)())
|
|
167
199
|
if payload is not None:
|
|
168
200
|
self._entity(endpoint, entity_type, payload, raw_hash)
|
|
201
|
+
if self.progress is not None:
|
|
202
|
+
self.progress.advance(completed=index)
|
|
169
203
|
|
|
170
204
|
def _daily(self, from_date: date, to_date: date) -> None:
|
|
171
205
|
calls = {
|
|
@@ -183,13 +217,26 @@ class GarminStagingWorker:
|
|
|
183
217
|
"nutrition_food_log": ("get_nutrition_daily_food_log", "nutrition_log"), "nutrition_meals": ("get_nutrition_daily_meals", "nutrition_log"),
|
|
184
218
|
"nutrition_settings": ("get_nutrition_daily_settings", "nutrition_setting"),
|
|
185
219
|
}
|
|
220
|
+
total_days = (to_date - from_date).days + 1
|
|
221
|
+
if self.progress is not None:
|
|
222
|
+
self.progress.set_stage("daily")
|
|
223
|
+
self.progress.advance(total=total_days)
|
|
186
224
|
current = from_date
|
|
225
|
+
completed_days = 0
|
|
187
226
|
while current <= to_date:
|
|
227
|
+
self._check_interrupted()
|
|
188
228
|
day = current.isoformat()
|
|
229
|
+
if self.progress is not None:
|
|
230
|
+
self.progress.advance(step=day)
|
|
189
231
|
for endpoint, (method, entity_type) in calls.items():
|
|
232
|
+
if self.progress is not None:
|
|
233
|
+
self.progress.advance(step=f"{day} {endpoint}")
|
|
190
234
|
payload, raw_hash = self._capture(endpoint, lambda method=method, day=day: getattr(self.api, method)(day), day, {"date": day})
|
|
191
235
|
if payload is not None:
|
|
192
236
|
self._entity(endpoint, entity_type, payload, raw_hash, day)
|
|
237
|
+
completed_days += 1
|
|
238
|
+
if self.progress is not None:
|
|
239
|
+
self.progress.advance(completed=completed_days)
|
|
193
240
|
current += timedelta(days=1)
|
|
194
241
|
|
|
195
242
|
def _range(self, from_date: date, to_date: date) -> None:
|
|
@@ -198,7 +245,10 @@ class GarminStagingWorker:
|
|
|
198
245
|
"blood_pressure": ("get_blood_pressure", "blood_pressure"), "weigh_ins": ("get_weigh_ins", "body_composition"),
|
|
199
246
|
"progress_summary": ("get_progress_summary_between_dates", "progress_summary"), "menstrual_calendar": ("get_menstrual_calendar_data", "menstrual"),
|
|
200
247
|
}
|
|
201
|
-
|
|
248
|
+
if self.progress is not None:
|
|
249
|
+
self.progress.set_stage("range")
|
|
250
|
+
self.progress.advance(total=len(calls))
|
|
251
|
+
for index, (endpoint, (method, entity_type)) in enumerate(calls.items(), start=1):
|
|
202
252
|
# Garmin rejects broad body-battery and menstrual-calendar ranges.
|
|
203
253
|
# Keep every request inside the provider's 92-day limit.
|
|
204
254
|
window_start = from_date
|
|
@@ -206,23 +256,36 @@ class GarminStagingWorker:
|
|
|
206
256
|
# ranges. Keep it deliberately small; the captures are
|
|
207
257
|
# content-addressed so overlapping retry windows are harmless.
|
|
208
258
|
window_days = 7 if endpoint == "body_battery" else (90 if endpoint == "menstrual_calendar" else (to_date - from_date).days + 1)
|
|
259
|
+
if self.progress is not None:
|
|
260
|
+
self.progress.advance(step=endpoint)
|
|
209
261
|
while window_start <= to_date:
|
|
262
|
+
self._check_interrupted()
|
|
210
263
|
window_end = min(to_date, window_start + timedelta(days=window_days - 1))
|
|
211
264
|
start, end = window_start.isoformat(), window_end.isoformat()
|
|
212
265
|
payload, raw_hash = self._capture(endpoint, lambda method=method, start=start, end=end: getattr(self.api, method)(start, end), None, {"from": start, "to": end})
|
|
213
266
|
if payload is not None:
|
|
214
267
|
self._entity(endpoint, entity_type, payload, raw_hash, fallback_scope=start)
|
|
215
268
|
window_start = window_end + timedelta(days=1)
|
|
269
|
+
if self.progress is not None:
|
|
270
|
+
self.progress.advance(completed=index)
|
|
216
271
|
|
|
217
272
|
def _cycling_ftp_history(self, from_date: date, to_date: date) -> None:
|
|
218
273
|
"""Stage daily cycling FTP setting history from Garmin's range endpoint."""
|
|
219
274
|
endpoint = "functional_threshold_power_range"
|
|
220
275
|
window_start = from_date
|
|
276
|
+
total_windows = (to_date - from_date).days // 89 + 1
|
|
277
|
+
if self.progress is not None:
|
|
278
|
+
self.progress.set_stage("ftp_history")
|
|
279
|
+
self.progress.advance(total=total_windows)
|
|
280
|
+
completed_windows = 0
|
|
221
281
|
while window_start <= to_date:
|
|
282
|
+
self._check_interrupted()
|
|
222
283
|
# This private Connect endpoint is not documented with a maximum
|
|
223
284
|
# range. Keep requests aligned with other conservative range calls.
|
|
224
285
|
window_end = min(to_date, window_start + timedelta(days=89))
|
|
225
286
|
start, end = window_start.isoformat(), window_end.isoformat()
|
|
287
|
+
if self.progress is not None:
|
|
288
|
+
self.progress.advance(step=start)
|
|
226
289
|
payload, raw_hash = self._capture(
|
|
227
290
|
endpoint,
|
|
228
291
|
lambda start=start, end=end: self.api.get_functional_threshold_power_range(
|
|
@@ -251,14 +314,25 @@ class GarminStagingWorker:
|
|
|
251
314
|
raw_hash,
|
|
252
315
|
occurred_on=occurred_on,
|
|
253
316
|
)
|
|
317
|
+
completed_windows += 1
|
|
318
|
+
if self.progress is not None:
|
|
319
|
+
self.progress.advance(completed=completed_windows)
|
|
254
320
|
window_start = window_end + timedelta(days=1)
|
|
255
321
|
|
|
256
322
|
def _max_metrics_history(self, from_date: date, to_date: date) -> None:
|
|
257
323
|
"""Stage range max-metric responses by their actual calendar date."""
|
|
258
324
|
window_start = from_date
|
|
325
|
+
total_windows = (to_date - from_date).days // 89 + 1
|
|
326
|
+
if self.progress is not None:
|
|
327
|
+
self.progress.set_stage("max_metrics")
|
|
328
|
+
self.progress.advance(total=total_windows)
|
|
329
|
+
completed_windows = 0
|
|
259
330
|
while window_start <= to_date:
|
|
331
|
+
self._check_interrupted()
|
|
260
332
|
window_end = min(to_date, window_start + timedelta(days=89))
|
|
261
333
|
start, end = window_start.isoformat(), window_end.isoformat()
|
|
334
|
+
if self.progress is not None:
|
|
335
|
+
self.progress.advance(step=start)
|
|
262
336
|
payload, raw_hash = self._capture(
|
|
263
337
|
"max_metrics_range",
|
|
264
338
|
lambda start=start, end=end: self.api.get_max_metrics_range(start, end),
|
|
@@ -286,14 +360,25 @@ class GarminStagingWorker:
|
|
|
286
360
|
for item in as_items(fallback):
|
|
287
361
|
self.writer.source_entity("max_metric", f"max_metrics:{day}", item, fallback_hash, occurred_on=day)
|
|
288
362
|
current += timedelta(days=1)
|
|
363
|
+
completed_windows += 1
|
|
364
|
+
if self.progress is not None:
|
|
365
|
+
self.progress.advance(completed=completed_windows)
|
|
289
366
|
window_start = window_end + timedelta(days=1)
|
|
290
367
|
|
|
291
368
|
def _hrv_history(self, from_date: date, to_date: date) -> None:
|
|
292
369
|
"""Stage the PR-402 HRV range API, retaining daily fallback coverage."""
|
|
293
370
|
window_start = from_date
|
|
371
|
+
total_windows = (to_date - from_date).days // 89 + 1
|
|
372
|
+
if self.progress is not None:
|
|
373
|
+
self.progress.set_stage("hrv_history")
|
|
374
|
+
self.progress.advance(total=total_windows)
|
|
375
|
+
completed_windows = 0
|
|
294
376
|
while window_start <= to_date:
|
|
377
|
+
self._check_interrupted()
|
|
295
378
|
window_end = min(to_date, window_start + timedelta(days=89))
|
|
296
379
|
start, end = window_start.isoformat(), window_end.isoformat()
|
|
380
|
+
if self.progress is not None:
|
|
381
|
+
self.progress.advance(step=start)
|
|
297
382
|
payload, raw_hash = self._capture(
|
|
298
383
|
"hrv_range",
|
|
299
384
|
lambda start=start, end=end: self.api.get_hrv_data_range(start, end),
|
|
@@ -315,14 +400,25 @@ class GarminStagingWorker:
|
|
|
315
400
|
if fallback is not None:
|
|
316
401
|
self.writer.source_entity("daily_health", f"hrv:{day}", as_dict(fallback), fallback_hash, occurred_on=day)
|
|
317
402
|
current += timedelta(days=1)
|
|
403
|
+
completed_windows += 1
|
|
404
|
+
if self.progress is not None:
|
|
405
|
+
self.progress.advance(completed=completed_windows)
|
|
318
406
|
window_start = window_end + timedelta(days=1)
|
|
319
407
|
|
|
320
408
|
def _score_history(self, from_date: date, to_date: date) -> None:
|
|
321
409
|
"""Stage daily-capable endurance-adjacent metrics from range endpoints."""
|
|
322
410
|
window_start = from_date
|
|
411
|
+
total_windows = (to_date - from_date).days // 89 + 1
|
|
412
|
+
if self.progress is not None:
|
|
413
|
+
self.progress.set_stage("scores")
|
|
414
|
+
self.progress.advance(total=total_windows)
|
|
415
|
+
completed_windows = 0
|
|
323
416
|
while window_start <= to_date:
|
|
417
|
+
self._check_interrupted()
|
|
324
418
|
window_end = min(to_date, window_start + timedelta(days=89))
|
|
325
419
|
start, end = window_start.isoformat(), window_end.isoformat()
|
|
420
|
+
if self.progress is not None:
|
|
421
|
+
self.progress.advance(step=start)
|
|
326
422
|
for endpoint, method, entity_type in (
|
|
327
423
|
("hill_score", lambda: self.api.get_hill_score(start, end), "hill_score"),
|
|
328
424
|
("running_tolerance", lambda: self.api.get_running_tolerance(start, end, aggregation="daily"), "running_tolerance"),
|
|
@@ -333,33 +429,56 @@ class GarminStagingWorker:
|
|
|
333
429
|
item_day = payload_date(item)
|
|
334
430
|
if item_day:
|
|
335
431
|
self.writer.source_entity(entity_type, f"{endpoint}:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
|
|
432
|
+
completed_windows += 1
|
|
433
|
+
if self.progress is not None:
|
|
434
|
+
self.progress.advance(completed=completed_windows)
|
|
336
435
|
window_start = window_end + timedelta(days=1)
|
|
337
436
|
|
|
338
437
|
# Garmin limits daily race-prediction windows to one year.
|
|
339
438
|
window_start = from_date
|
|
439
|
+
total_windows = (to_date - from_date).days // 365 + 1
|
|
440
|
+
if self.progress is not None:
|
|
441
|
+
self.progress.advance(total=total_windows)
|
|
442
|
+
completed_windows = 0
|
|
340
443
|
while window_start <= to_date:
|
|
444
|
+
self._check_interrupted()
|
|
341
445
|
window_end = min(to_date, window_start + timedelta(days=365))
|
|
342
446
|
start, end = window_start.isoformat(), window_end.isoformat()
|
|
447
|
+
if self.progress is not None:
|
|
448
|
+
self.progress.advance(step=start)
|
|
343
449
|
payload, raw_hash = self._capture("race_predictions", lambda start=start, end=end: self.api.get_race_predictions(start, end, "daily"), None, {"from": start, "to": end, "aggregation": "daily"})
|
|
344
450
|
if payload is not None:
|
|
345
451
|
for index, item in enumerate(as_items(payload)):
|
|
346
452
|
item_day = payload_date(item) or start
|
|
347
453
|
self.writer.source_entity("race_prediction", f"race_prediction:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
|
|
454
|
+
completed_windows += 1
|
|
455
|
+
if self.progress is not None:
|
|
456
|
+
self.progress.advance(completed=completed_windows)
|
|
348
457
|
window_start = window_end + timedelta(days=1)
|
|
349
458
|
|
|
350
459
|
def _activities(self, from_date: date, to_date: date) -> None:
|
|
351
460
|
start, end = from_date.isoformat(), to_date.isoformat()
|
|
461
|
+
if self.progress is not None:
|
|
462
|
+
self.progress.set_stage("activities")
|
|
352
463
|
payload, raw_hash = self._capture("activities", lambda: self.api.get_activities_by_date(start, end), None, {"from": start, "to": end})
|
|
353
464
|
if payload is None:
|
|
354
465
|
return
|
|
355
466
|
self._entity("activities", "activity", payload, raw_hash)
|
|
356
|
-
|
|
467
|
+
summaries = as_items(payload)
|
|
468
|
+
if self.progress is not None:
|
|
469
|
+
self.progress.advance(total=len(summaries))
|
|
470
|
+
for index, summary in enumerate(summaries, start=1):
|
|
471
|
+
self._check_interrupted()
|
|
357
472
|
activity_id = remote_id(summary, "unknown")
|
|
473
|
+
if self.progress is not None:
|
|
474
|
+
self.progress.advance(step=activity_id)
|
|
358
475
|
summary_hash = hashlib.sha256(json.dumps(summary, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()
|
|
359
476
|
details_fetched = self.known_activity_hashes.get(activity_id) != summary_hash
|
|
360
477
|
if details_fetched:
|
|
361
478
|
self._activity_details(activity_id, summary)
|
|
362
479
|
self.writer.activity_sync_state(activity_id, summary_hash, details_fetched)
|
|
480
|
+
if self.progress is not None:
|
|
481
|
+
self.progress.advance(completed=index)
|
|
363
482
|
|
|
364
483
|
def _multisport_children(self, parent_activity_id: str, activity: dict[str, Any]) -> None:
|
|
365
484
|
if not activity.get("isMultiSportParent"):
|
|
@@ -444,7 +563,15 @@ class GarminStagingWorker:
|
|
|
444
563
|
"workouts": ("get_workouts", (), "workout"), "scheduled_workouts": ("get_scheduled_workouts", (today.year, today.month), "scheduled_workout"),
|
|
445
564
|
"goals": ("get_goals", (), "goal"), "golf_summary": ("get_golf_summary", (), "golf_scorecard"),
|
|
446
565
|
}
|
|
447
|
-
|
|
566
|
+
if self.progress is not None:
|
|
567
|
+
self.progress.set_stage("collections")
|
|
568
|
+
self.progress.advance(total=len(calls))
|
|
569
|
+
for index, (endpoint, (method, arguments, entity_type)) in enumerate(calls.items(), start=1):
|
|
570
|
+
self._check_interrupted()
|
|
571
|
+
if self.progress is not None:
|
|
572
|
+
self.progress.advance(step=endpoint)
|
|
448
573
|
payload, raw_hash = self._capture(endpoint, lambda method=method, arguments=arguments: getattr(self.api, method)(*arguments))
|
|
449
574
|
if payload is not None:
|
|
450
575
|
self._entity(endpoint, entity_type, payload, raw_hash)
|
|
576
|
+
if self.progress is not None:
|
|
577
|
+
self.progress.advance(completed=index)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Discover OpenCode Go models and emit Catence Console profiles for them.
|
|
2
|
+
//
|
|
3
|
+
// OpenCode Go serves its catalog through three APIs: most models speak the
|
|
4
|
+
// OpenAI Chat Completions API, Grok and GPT-5.6 Luna speak the OpenAI
|
|
5
|
+
// Responses API, and MiniMax and Qwen speak the Anthropic Messages API. The
|
|
6
|
+
// public `GET /models` endpoint returns only model ids, not which API each
|
|
7
|
+
// model needs, so routing comes from the trusted table below and is intersected
|
|
8
|
+
// with the live list (the same "live discovery + trusted metadata" approach
|
|
9
|
+
// OpenClaw uses).
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// node scripts/discover-opencode-go.mjs [--base-url <url>] [--write <config.json>] [--set-default]
|
|
13
|
+
//
|
|
14
|
+
// Flags:
|
|
15
|
+
// --base-url <url> Root of the OpenCode Go API (default https://opencode.ai/zen/go/v1).
|
|
16
|
+
// --write <path> Merge the two profiles into an existing config.json instead of printing to stdout.
|
|
17
|
+
// --set-default With --write, set console.defaultProfile to opencode-go even when one already exists.
|
|
18
|
+
|
|
19
|
+
import process from 'node:process';
|
|
20
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_BASE_URL = 'https://opencode.ai/zen/go/v1';
|
|
23
|
+
const API_KEY_ENV = 'OPENCODE_GO_API_KEY';
|
|
24
|
+
const API_BASE_ENV = 'OPENCODE_GO_API_BASE';
|
|
25
|
+
const MESSAGES_BASE_ENV = 'OPENCODE_GO_MESSAGES_API_BASE';
|
|
26
|
+
|
|
27
|
+
// LiteLLM appends a fixed suffix to the profile base URL per provider, so the
|
|
28
|
+
// three APIs need different prefixes and two base URLs:
|
|
29
|
+
// openai/<id> -> {base}/chat/completions
|
|
30
|
+
// openai/responses/<id> -> {base}/responses
|
|
31
|
+
// anthropic/<id> -> {base minus /v1}/v1/messages
|
|
32
|
+
const RESPONSES_MODELS = new Set(['grok-4.5', 'gpt-5.6-luna']);
|
|
33
|
+
const MESSAGES_MODELS = new Set([
|
|
34
|
+
'minimax-m2.5',
|
|
35
|
+
'minimax-m2.7',
|
|
36
|
+
'minimax-m3',
|
|
37
|
+
'qwen3.5-plus',
|
|
38
|
+
'qwen3.6-plus',
|
|
39
|
+
'qwen3.7-plus',
|
|
40
|
+
'qwen3.7-max',
|
|
41
|
+
'qwen3.8-max',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const BRAND_NAMES = new Map([
|
|
45
|
+
['deepseek', 'DeepSeek'],
|
|
46
|
+
['glm', 'GLM'],
|
|
47
|
+
['gpt', 'GPT'],
|
|
48
|
+
['grok', 'Grok'],
|
|
49
|
+
['hy3', 'Hy3'],
|
|
50
|
+
['kimi', 'Kimi'],
|
|
51
|
+
['mimo', 'MiMo'],
|
|
52
|
+
['minimax', 'MiniMax'],
|
|
53
|
+
['qwen', 'Qwen'],
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
function classify(id) {
|
|
57
|
+
if (RESPONSES_MODELS.has(id)) return { route: 'responses', guessed: false };
|
|
58
|
+
if (MESSAGES_MODELS.has(id)) return { route: 'messages', guessed: false };
|
|
59
|
+
if (/^(grok-|gpt-)/.test(id)) return { route: 'responses', guessed: true };
|
|
60
|
+
if (/^(minimax-|qwen)/.test(id)) return { route: 'messages', guessed: true };
|
|
61
|
+
return { route: 'chat', guessed: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function modelReference(id, route) {
|
|
65
|
+
if (route === 'responses') return `openai/responses/${id}`;
|
|
66
|
+
if (route === 'messages') return `anthropic/${id}`;
|
|
67
|
+
return `openai/${id}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function labelFor(id) {
|
|
71
|
+
return id
|
|
72
|
+
.split('-')
|
|
73
|
+
.map((segment) => BRAND_NAMES.get(segment) ?? segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
74
|
+
.join(' ');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function usage() {
|
|
78
|
+
process.stderr.write(
|
|
79
|
+
'Usage: node scripts/discover-opencode-go.mjs [--base-url <url>] [--write <config.json>] [--set-default]\n',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function main() {
|
|
84
|
+
const args = process.argv.slice(2);
|
|
85
|
+
let baseUrl = DEFAULT_BASE_URL;
|
|
86
|
+
let writePath = null;
|
|
87
|
+
let setDefault = false;
|
|
88
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
89
|
+
const arg = args[i];
|
|
90
|
+
if (arg === '--base-url') {
|
|
91
|
+
baseUrl = args[i + 1];
|
|
92
|
+
if (!baseUrl) throw new Error('--base-url requires a URL.');
|
|
93
|
+
i += 1;
|
|
94
|
+
} else if (arg === '--write') {
|
|
95
|
+
writePath = args[i + 1];
|
|
96
|
+
if (!writePath) throw new Error('--write requires a config.json path.');
|
|
97
|
+
i += 1;
|
|
98
|
+
} else if (arg === '--set-default') {
|
|
99
|
+
setDefault = true;
|
|
100
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
101
|
+
usage();
|
|
102
|
+
return;
|
|
103
|
+
} else {
|
|
104
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!baseUrl.endsWith('/v1')) {
|
|
109
|
+
process.stderr.write(`Warning: --base-url ${baseUrl} does not end in /v1; chat and responses URLs may be wrong.\n`);
|
|
110
|
+
}
|
|
111
|
+
const modelsUrl = `${baseUrl.replace(/\/$/, '')}/models`;
|
|
112
|
+
const messagesBase = baseUrl.replace(/\/v1\/?$/, '');
|
|
113
|
+
|
|
114
|
+
let response;
|
|
115
|
+
try {
|
|
116
|
+
response = await fetch(modelsUrl, { headers: { accept: 'application/json' } });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw new Error(`Could not reach ${modelsUrl}: ${error.message}`);
|
|
119
|
+
}
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error(`GET ${modelsUrl} returned HTTP ${response.status}.`);
|
|
122
|
+
}
|
|
123
|
+
const payload = await response.json();
|
|
124
|
+
const ids = (payload.data ?? []).map((entry) => entry.id).filter((id) => typeof id === 'string' && id);
|
|
125
|
+
|
|
126
|
+
const groups = { chat: [], responses: [], messages: [] };
|
|
127
|
+
const guessed = [];
|
|
128
|
+
for (const id of ids) {
|
|
129
|
+
const { route, guessed: wasGuessed } = classify(id);
|
|
130
|
+
if (!(route in groups)) continue;
|
|
131
|
+
groups[route].push(id);
|
|
132
|
+
if (wasGuessed) guessed.push(`${id} -> ${route}`);
|
|
133
|
+
}
|
|
134
|
+
for (const route of Object.keys(groups)) groups[route].sort();
|
|
135
|
+
|
|
136
|
+
const chatProfileModels = {};
|
|
137
|
+
for (const id of groups.chat) {
|
|
138
|
+
chatProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'chat') };
|
|
139
|
+
}
|
|
140
|
+
for (const id of groups.responses) {
|
|
141
|
+
chatProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'responses') };
|
|
142
|
+
}
|
|
143
|
+
const messagesProfileModels = {};
|
|
144
|
+
for (const id of groups.messages) {
|
|
145
|
+
messagesProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'messages') };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const chatDefault = groups.chat[0] ?? groups.responses[0];
|
|
149
|
+
const profiles = {};
|
|
150
|
+
profiles['opencode-go'] = {
|
|
151
|
+
label: 'OpenCode Go',
|
|
152
|
+
...(chatDefault ? { defaultModel: chatDefault } : {}),
|
|
153
|
+
models: chatProfileModels,
|
|
154
|
+
apiKeyEnv: API_KEY_ENV,
|
|
155
|
+
apiBaseEnv: API_BASE_ENV,
|
|
156
|
+
};
|
|
157
|
+
if (messagesProfileModels && groups.messages.length) {
|
|
158
|
+
profiles['opencode-go-messages'] = {
|
|
159
|
+
label: 'OpenCode Go (Anthropic)',
|
|
160
|
+
defaultModel: groups.messages[0],
|
|
161
|
+
models: messagesProfileModels,
|
|
162
|
+
apiKeyEnv: API_KEY_ENV,
|
|
163
|
+
apiBaseEnv: MESSAGES_BASE_ENV,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const consoleSection = { defaultProfile: 'opencode-go', profiles };
|
|
168
|
+
|
|
169
|
+
if (guessed.length) {
|
|
170
|
+
process.stderr.write('Routed by prefix heuristic (verify before use):\n');
|
|
171
|
+
for (const line of guessed) process.stderr.write(` ${line}\n`);
|
|
172
|
+
}
|
|
173
|
+
process.stderr.write(
|
|
174
|
+
`Discovered ${ids.length} models (${groups.chat.length} chat, ${groups.responses.length} responses, ${groups.messages.length} messages).\n`,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (writePath) {
|
|
178
|
+
let root = {};
|
|
179
|
+
try {
|
|
180
|
+
root = JSON.parse(await readFile(writePath, 'utf8'));
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error.code !== 'ENOENT') throw new Error(`Could not read ${writePath}: ${error.message}`);
|
|
183
|
+
}
|
|
184
|
+
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
|
|
185
|
+
throw new Error(`${writePath} must contain a JSON object.`);
|
|
186
|
+
}
|
|
187
|
+
const existingConsole = typeof root.console === 'object' && root.console !== null ? root.console : {};
|
|
188
|
+
const mergedProfiles = { ...(existingConsole.profiles ?? {}), ...profiles };
|
|
189
|
+
const defaultProfile =
|
|
190
|
+
existingConsole.defaultProfile && !setDefault ? existingConsole.defaultProfile : 'opencode-go';
|
|
191
|
+
root.console = { ...existingConsole, defaultProfile, profiles: mergedProfiles };
|
|
192
|
+
await writeFile(writePath, `${JSON.stringify(root, null, 2)}\n`);
|
|
193
|
+
process.stderr.write(`Merged profiles into ${writePath} (defaultProfile: ${defaultProfile}).\n`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
process.stderr.write(
|
|
198
|
+
`Export the key and base URLs, then add the JSON below under "console" in config.json:\n` +
|
|
199
|
+
` export ${API_KEY_ENV}='sk-…'\n` +
|
|
200
|
+
` export ${API_BASE_ENV}='${baseUrl}'\n` +
|
|
201
|
+
` export ${MESSAGES_BASE_ENV}='${messagesBase}'\n` +
|
|
202
|
+
`(or rerun with --write ~/.catence/config.json to merge into an existing config.)\n`,
|
|
203
|
+
);
|
|
204
|
+
process.stdout.write(`${JSON.stringify(consoleSection, null, 2)}\n`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await main().catch((error) => {
|
|
208
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
});
|