catence 0.2.0-beta.6 → 0.2.0-beta.8
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 +42 -0
- package/config.example.json +21 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/docs/deployment.md +83 -4
- package/docs/llm-providers.md +85 -0
- package/package.json +3 -1
- package/scripts/discover-opencode-zen.mjs +218 -0
package/README.md
CHANGED
|
@@ -213,6 +213,34 @@ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.e
|
|
|
213
213
|
|
|
214
214
|
[`config.example.json`](config.example.json) documents rate limits, Strava budgets, and Console model profiles. It intentionally contains environment-variable names rather than credential values. Keep per-athlete provider values in `catence-data secret set`, not in `.env` or `config.json`.
|
|
215
215
|
|
|
216
|
+
### Per-model reasoning effort
|
|
217
|
+
|
|
218
|
+
Models that support reasoning effort (e.g., DeepSeek V4 Flash on Opencode Zen) can set it per deployment in `config.json`:
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
{
|
|
222
|
+
"console": {
|
|
223
|
+
"profiles": {
|
|
224
|
+
"opencode-zen": {
|
|
225
|
+
"label": "OpenCode Zen",
|
|
226
|
+
"defaultModel": "deepseek-v4-flash-free",
|
|
227
|
+
"models": {
|
|
228
|
+
"deepseek-v4-flash-free": {
|
|
229
|
+
"label": "DeepSeek V4 Flash Free",
|
|
230
|
+
"model": "openai/deepseek-v4-flash-free",
|
|
231
|
+
"reasoningEffort": "high"
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
235
|
+
"apiBaseEnv": "OPENCODE_ZEN_API_BASE"
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Supported values: `minimal`, `low`, `medium`, `high` (OpenAI standard). The model-level value overrides any profile-level `defaultReasoningEffort`. If neither is set, the provider default is used.
|
|
243
|
+
|
|
216
244
|
For source development:
|
|
217
245
|
|
|
218
246
|
```sh
|
|
@@ -238,3 +266,17 @@ data.
|
|
|
238
266
|
Garmin is the primary source for activity and wellness data; Intervals.icu supplements training analysis; Strava is used for targeted segments, efforts, and gear. Missing fields mean a provider did not supply a value. The source credentials and source APIs determine the data available. Catence does not use the official Garmin API; Garmin sync relies on the selected athlete’s email/password credentials.
|
|
239
267
|
|
|
240
268
|
See [distribution notes](docs/distribution.md) for release artifacts, including APM and MCPB demo bundles.
|
|
269
|
+
|
|
270
|
+
##
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
cd catence-deploy
|
|
274
|
+
docker compose -f docker-compose.yml --env-file .env exec console \
|
|
275
|
+
catence-data sync --athlete martina --provider all --home /data # incremental sync, all providers
|
|
276
|
+
docker compose -f docker-compose.yml --env-file .env exec console \
|
|
277
|
+
catence-data backfill --athlete martina --from 2026-01-01 --home /data # full backfill from a date
|
|
278
|
+
docker compose -f docker-compose.yml --env-file .env exec console \
|
|
279
|
+
catence-data status --athlete martina --home /data # coverage/errors
|
|
280
|
+
docker compose -f docker-compose.yml --env-file .env exec console \
|
|
281
|
+
catence-data athlete list --home /data # list athlete IDs
|
|
282
|
+
```
|
package/config.example.json
CHANGED
|
@@ -44,6 +44,27 @@
|
|
|
44
44
|
"label": "Anthropic",
|
|
45
45
|
"model": "anthropic/claude-sonnet-4-5",
|
|
46
46
|
"apiKeyEnv": "ANTHROPIC_API_KEY"
|
|
47
|
+
},
|
|
48
|
+
"opencode-zen": {
|
|
49
|
+
"label": "OpenCode Zen",
|
|
50
|
+
"defaultModel": "deepseek-v4-flash-free",
|
|
51
|
+
"models": {
|
|
52
|
+
"deepseek-v4-flash-free": { "label": "DeepSeek V4 Flash Free", "model": "openai/deepseek-v4-flash-free" },
|
|
53
|
+
"nemotron-3-ultra-free": { "label": "Nemotron 3 Ultra Free", "model": "openai/nemotron-3-ultra-free" },
|
|
54
|
+
"big-pickle": { "label": "Big Pickle", "model": "openai/big-pickle" }
|
|
55
|
+
},
|
|
56
|
+
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
57
|
+
"apiBaseEnv": "OPENCODE_ZEN_API_BASE"
|
|
58
|
+
},
|
|
59
|
+
"opencode-zen-responses": {
|
|
60
|
+
"label": "OpenCode Zen (Responses)",
|
|
61
|
+
"defaultModel": "gpt-5.6-luna",
|
|
62
|
+
"models": {
|
|
63
|
+
"gpt-5.6-luna": { "label": "GPT 5.6 Luna", "model": "openai/responses/gpt-5.6-luna" },
|
|
64
|
+
"gpt-5.6-terra": { "label": "GPT 5.6 Terra", "model": "openai/responses/gpt-5.6-terra" }
|
|
65
|
+
},
|
|
66
|
+
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
67
|
+
"apiBaseEnv": "OPENCODE_ZEN_RESPONSES_API_BASE"
|
|
47
68
|
}
|
|
48
69
|
}
|
|
49
70
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.8";
|
|
7
7
|
export declare const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export type CatenceRuntimeCapabilities = {
|
|
9
9
|
mcp: true;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.8';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
package/docs/deployment.md
CHANGED
|
@@ -59,13 +59,24 @@ that already holds only Console artifacts (`config.json`, `console/`).
|
|
|
59
59
|
|
|
60
60
|
### 3. Store provider credentials (stdin only, never shell history)
|
|
61
61
|
|
|
62
|
+
Garmin (`email`, `password`), Intervals.icu (`apiKey`, `athleteId`), and Strava
|
|
63
|
+
(`clientId`, `clientSecret`):
|
|
64
|
+
|
|
62
65
|
```sh
|
|
66
|
+
# Garmin
|
|
63
67
|
printf %s 'alex@example.com' | catence-data --athlete alex secret set --provider garmin --field email --value-stdin
|
|
64
68
|
printf %s 'your-garmin-password' | catence-data --athlete alex secret set --provider garmin --field password --value-stdin
|
|
69
|
+
# Intervals.icu
|
|
65
70
|
printf %s 'intervals-api-key' | catence-data --athlete alex secret set --provider intervals --field apiKey --value-stdin
|
|
66
71
|
printf %s '12345' | catence-data --athlete alex secret set --provider intervals --field athleteId --value-stdin
|
|
72
|
+
# Strava (create an API application at https://www.strava.com/settings/api)
|
|
73
|
+
printf %s 'strava-client-id' | catence-data --athlete alex secret set --provider strava --field clientId --value-stdin
|
|
74
|
+
printf %s 'strava-client-secret' | catence-data --athlete alex secret set --provider strava --field clientSecret --value-stdin
|
|
67
75
|
```
|
|
68
76
|
|
|
77
|
+
Set only the providers you actually sync; `secret set` accepts each field
|
|
78
|
+
independently.
|
|
79
|
+
|
|
69
80
|
### 4. Sync data and build retrieval context
|
|
70
81
|
|
|
71
82
|
```sh
|
|
@@ -153,14 +164,35 @@ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.e
|
|
|
153
164
|
|
|
154
165
|
### 4. Store provider credentials
|
|
155
166
|
|
|
167
|
+
Garmin (`email`, `password`), Intervals.icu (`apiKey`, `athleteId`), and Strava
|
|
168
|
+
(`clientId`, `clientSecret`), one `printf` per field:
|
|
169
|
+
|
|
156
170
|
```sh
|
|
157
|
-
|
|
171
|
+
# Garmin
|
|
172
|
+
printf %s 'alex@example.com' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
|
|
173
|
+
--entrypoint catence-data console --athlete alex secret set \
|
|
174
|
+
--provider garmin --field email --value-stdin
|
|
175
|
+
printf %s 'your-garmin-password' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
|
|
176
|
+
--entrypoint catence-data console --athlete alex secret set \
|
|
177
|
+
--provider garmin --field password --value-stdin
|
|
178
|
+
# Intervals.icu
|
|
179
|
+
printf %s 'intervals-api-key' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
|
|
158
180
|
--entrypoint catence-data console --athlete alex secret set \
|
|
159
181
|
--provider intervals --field apiKey --value-stdin
|
|
182
|
+
printf %s '12345' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
|
|
183
|
+
--entrypoint catence-data console --athlete alex secret set \
|
|
184
|
+
--provider intervals --field athleteId --value-stdin
|
|
185
|
+
# Strava (create an API application at https://www.strava.com/settings/api)
|
|
186
|
+
printf %s 'strava-client-id' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
|
|
187
|
+
--entrypoint catence-data console --athlete alex secret set \
|
|
188
|
+
--provider strava --field clientId --value-stdin
|
|
189
|
+
printf %s 'strava-client-secret' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
|
|
190
|
+
--entrypoint catence-data console --athlete alex secret set \
|
|
191
|
+
--provider strava --field clientSecret --value-stdin
|
|
160
192
|
```
|
|
161
193
|
|
|
162
|
-
|
|
163
|
-
`auth strava`.
|
|
194
|
+
Set only the providers you actually sync. Use the same `--entrypoint
|
|
195
|
+
catence-data` pattern for `sync`, `backfill`, and `auth strava`.
|
|
164
196
|
|
|
165
197
|
### 5. Configure the model
|
|
166
198
|
|
|
@@ -176,7 +208,54 @@ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.e
|
|
|
176
208
|
With `--home <dir>` (bind mount), edit `<dir>/config.json` on the host
|
|
177
209
|
instead. To add OpenCode Go models, see [model discovery](#model-discovery-opencode-go).
|
|
178
210
|
|
|
179
|
-
### 6.
|
|
211
|
+
### 6. Verify with doctor
|
|
212
|
+
|
|
213
|
+
`catence-console doctor` checks each profile's environment variables and the
|
|
214
|
+
local Catence runtime. The deploy scaffold includes a helper for it:
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
./catence-deploy/doctor.sh
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The helper runs the doctor *inside the running container* with `exec` — the
|
|
221
|
+
container's own loopback holds the live MCP server at `127.0.0.1:8787`, and the
|
|
222
|
+
provider keys from `.env` are already in its environment. (A `docker compose
|
|
223
|
+
run` container has a separate loopback and would report the runtime as
|
|
224
|
+
unreachable.) The equivalent long form is:
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env exec console \
|
|
228
|
+
/opt/catence-console/bin/catence-console doctor --home /data --mcp-url http://127.0.0.1:8787/mcp
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Expect `"ready": true` once the model key is set; missing keys show up per
|
|
232
|
+
profile without ever printing their values.
|
|
233
|
+
|
|
234
|
+
### 7. Sync, backfill, and inspect athlete data
|
|
235
|
+
|
|
236
|
+
The deploy scaffold includes a helper that runs `catence-data` *inside the
|
|
237
|
+
running container* with `exec`, so the athlete's provider secrets from the
|
|
238
|
+
data volume are already available:
|
|
239
|
+
|
|
240
|
+
```sh
|
|
241
|
+
./catence-deploy/sync.sh --athlete alex sync # incremental sync, all providers
|
|
242
|
+
./catence-deploy/sync.sh --athlete alex backfill 2026-01-01 # full backfill from a date
|
|
243
|
+
./catence-deploy/sync.sh --athlete alex status # coverage and errors
|
|
244
|
+
./catence-deploy/sync.sh athletes # list athlete IDs
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`sync` defaults to `--provider all`; extra flags pass through, for example
|
|
248
|
+
`./catence-deploy/sync.sh --athlete alex sync --refresh` or
|
|
249
|
+
`./catence-deploy/sync.sh --athlete alex backfill 2026-01-01 --provider garmin`.
|
|
250
|
+
Set a default athlete with `export CATENCE_ATHLETE=alex` to drop the
|
|
251
|
+
`--athlete` argument. The equivalent long form is:
|
|
252
|
+
|
|
253
|
+
```sh
|
|
254
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env exec console \
|
|
255
|
+
catence-data sync --athlete alex --provider all --home /data
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### 8. Expose the Console safely
|
|
180
259
|
|
|
181
260
|
Keep the Console port bound to `127.0.0.1` (the default) and front it with a
|
|
182
261
|
Cloudflare Tunnel or reverse proxy at HTTPS. Never expose Catence's MCP port
|
package/docs/llm-providers.md
CHANGED
|
@@ -272,6 +272,83 @@ Notes:
|
|
|
272
272
|
`OPENCODE_LLM_PROXY_TOKEN`; otherwise any non-empty value satisfies the
|
|
273
273
|
profile's required-environment check.
|
|
274
274
|
|
|
275
|
+
### Opencode Zen
|
|
276
|
+
|
|
277
|
+
[OpenCode Zen](https://opencode.ai/zen) is a hosted model API with a curated
|
|
278
|
+
selection of coding and reasoning models. It exposes an OpenAI-compatible
|
|
279
|
+
endpoint at `https://opencode.ai/zen/v1` with a public `GET /v1/models`
|
|
280
|
+
listing. Several models are available for free (no billing details required);
|
|
281
|
+
paid models require an API key from [opencode.ai/auth](https://opencode.ai/auth).
|
|
282
|
+
|
|
283
|
+
The repository includes a discovery script that fetches the live model list and
|
|
284
|
+
merges ready-made Console profiles into `config.json`. From a Catence checkout:
|
|
285
|
+
|
|
286
|
+
```sh
|
|
287
|
+
npm run discover:opencode-zen -- --write ~/.catence/config.json
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Add `--free-only` to include only the free models.
|
|
291
|
+
|
|
292
|
+
The script adds two profiles, because OpenCode Zen routes model families through
|
|
293
|
+
different API shapes:
|
|
294
|
+
|
|
295
|
+
- `opencode-zen` — chat/completions models (most models, including all free
|
|
296
|
+
models), base `https://opencode.ai/zen/v1` (LiteLLM `openai/<id>` names).
|
|
297
|
+
- `opencode-zen-responses` — responses API models (GPT-5.x series), base
|
|
298
|
+
`https://opencode.ai/zen/v1` (LiteLLM `openai/responses/<id>` names).
|
|
299
|
+
|
|
300
|
+
| Family | Profile prefix | Example |
|
|
301
|
+
| --- | --- | --- |
|
|
302
|
+
| Chat | `openai/<id>` | `openai/deepseek-v4-flash-free` |
|
|
303
|
+
| Responses | `openai/responses/<id>` | `openai/responses/gpt-5.6-luna` |
|
|
304
|
+
|
|
305
|
+
Free models carry a `-free` suffix (e.g., `deepseek-v4-flash-free`,
|
|
306
|
+
`nemotron-3-ultra-free`, `big-pickle`). The discovery script recognises them
|
|
307
|
+
automatically; `--free-only` filters the list to free models only.
|
|
308
|
+
|
|
309
|
+
```sh
|
|
310
|
+
export OPENCODE_API_KEY='…' # required for paid models; any non-empty value for free
|
|
311
|
+
export OPENCODE_ZEN_API_BASE='https://opencode.ai/zen/v1'
|
|
312
|
+
export OPENCODE_ZEN_RESPONSES_API_BASE='https://opencode.ai/zen/v1'
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
```jsonc
|
|
316
|
+
{
|
|
317
|
+
"console": {
|
|
318
|
+
"defaultProfile": "opencode-zen",
|
|
319
|
+
"profiles": {
|
|
320
|
+
"opencode-zen": {
|
|
321
|
+
"label": "OpenCode Zen",
|
|
322
|
+
"defaultModel": "deepseek-v4-flash-free",
|
|
323
|
+
"models": {
|
|
324
|
+
"deepseek-v4-flash-free": { "label": "DeepSeek V4 Flash Free", "model": "openai/deepseek-v4-flash-free" },
|
|
325
|
+
"nemotron-3-ultra-free": { "label": "Nemotron 3 Ultra Free", "model": "openai/nemotron-3-ultra-free" },
|
|
326
|
+
"big-pickle": { "label": "Big Pickle", "model": "openai/big-pickle" }
|
|
327
|
+
},
|
|
328
|
+
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
329
|
+
"apiBaseEnv": "OPENCODE_ZEN_API_BASE"
|
|
330
|
+
},
|
|
331
|
+
"opencode-zen-responses": {
|
|
332
|
+
"label": "OpenCode Zen (Responses)",
|
|
333
|
+
"defaultModel": "gpt-5.6-luna",
|
|
334
|
+
"models": {
|
|
335
|
+
"gpt-5.6-luna": { "label": "GPT 5.6 Luna", "model": "openai/responses/gpt-5.6-luna" },
|
|
336
|
+
"gpt-5.6-terra": { "label": "GPT 5.6 Terra", "model": "openai/responses/gpt-5.6-terra" }
|
|
337
|
+
},
|
|
338
|
+
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
339
|
+
"apiBaseEnv": "OPENCODE_ZEN_RESPONSES_API_BASE"
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Verify with `catence-console doctor`. The `responses` models
|
|
347
|
+
(`openai/responses/…`) go through LiteLLM's responses bridge and are the least
|
|
348
|
+
battle-tested path; smoke-test one chat turn before relying on them.
|
|
349
|
+
|
|
350
|
+
The same `OPENCODE_API_KEY` works for both Opencode Go and Opencode Zen.
|
|
351
|
+
|
|
275
352
|
## Multiple deployments per provider
|
|
276
353
|
|
|
277
354
|
A profile can list several models under one set of credentials; the Console
|
|
@@ -306,6 +383,14 @@ prints credential values.
|
|
|
306
383
|
catence-console doctor --home "$HOME/.catence" --mcp-url http://127.0.0.1:8787/mcp
|
|
307
384
|
```
|
|
308
385
|
|
|
386
|
+
On Docker, the deploy scaffold includes a helper that runs it inside the
|
|
387
|
+
running container (`exec`, not `run` — the container's loopback hosts the live
|
|
388
|
+
MCP server and the `.env` provider keys are in its environment):
|
|
389
|
+
|
|
390
|
+
```sh
|
|
391
|
+
./catence-deploy/doctor.sh
|
|
392
|
+
```
|
|
393
|
+
|
|
309
394
|
## Docker: generating the Console login hash
|
|
310
395
|
|
|
311
396
|
`deploy-console.sh` writes a small helper into the deployment directory. Run it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "catence",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.8",
|
|
4
4
|
"description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"config.example.json",
|
|
30
30
|
"docs",
|
|
31
31
|
"scripts/discover-opencode-go.mjs",
|
|
32
|
+
"scripts/discover-opencode-zen.mjs",
|
|
32
33
|
"pyproject.toml",
|
|
33
34
|
"uv.lock",
|
|
34
35
|
"python/__init__.py",
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
"console": "uv run --project console catence-console serve",
|
|
58
59
|
"console:doctor": "uv run --project console catence-console doctor",
|
|
59
60
|
"discover:opencode-go": "node scripts/discover-opencode-go.mjs",
|
|
61
|
+
"discover:opencode-zen": "node scripts/discover-opencode-zen.mjs",
|
|
60
62
|
"package:check": "npm pack --dry-run",
|
|
61
63
|
"release:check": "node scripts/release-artifacts.mjs check",
|
|
62
64
|
"mcpb:validate": "npm exec --yes --package @anthropic-ai/mcpb -- mcpb validate mcpb/manifest.json",
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Discover OpenCode Zen models and emit Catence Console profiles for them.
|
|
2
|
+
//
|
|
3
|
+
// OpenCode Zen serves its catalog through `GET /v1/models` at
|
|
4
|
+
// `https://opencode.ai/zen/v1`. Models are split between the chat/completions
|
|
5
|
+
// endpoint and the responses endpoint. Routing is determined by model ID prefix:
|
|
6
|
+
// GPT-5.x models use the responses API; everything else uses chat/completions.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// node scripts/discover-opencode-zen.mjs [--base-url <url>] [--write <config.json>] [--set-default] [--free-only]
|
|
10
|
+
//
|
|
11
|
+
// Flags:
|
|
12
|
+
// --base-url <url> Root of the OpenCode Zen API (default https://opencode.ai/zen/v1).
|
|
13
|
+
// --write <path> Merge the two profiles into an existing config.json instead of printing to stdout.
|
|
14
|
+
// --set-default With --write, set console.defaultProfile to opencode-zen even when one already exists.
|
|
15
|
+
// --free-only Only include free models (those with -free suffix or known free model IDs).
|
|
16
|
+
|
|
17
|
+
import process from 'node:process';
|
|
18
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_BASE_URL = 'https://opencode.ai/zen/v1';
|
|
21
|
+
const API_KEY_ENV = 'OPENCODE_API_KEY';
|
|
22
|
+
const API_BASE_ENV = 'OPENCODE_ZEN_API_BASE';
|
|
23
|
+
const RESPONSES_BASE_ENV = 'OPENCODE_ZEN_RESPONSES_API_BASE';
|
|
24
|
+
|
|
25
|
+
// Models that use the OpenAI Responses API (gpt-5.x series)
|
|
26
|
+
const RESPONSES_PREFIXES = ['gpt-5', 'gpt-5.'];
|
|
27
|
+
|
|
28
|
+
// Known free model IDs (in case the API doesn't mark them explicitly)
|
|
29
|
+
const KNOWN_FREE_MODELS = new Set([
|
|
30
|
+
'deepseek-v4-flash-free',
|
|
31
|
+
'mimo-v2.5-free',
|
|
32
|
+
'nemotron-3-ultra-free',
|
|
33
|
+
'nemotron-3.5-lightning-free',
|
|
34
|
+
'hy3-free',
|
|
35
|
+
'laguna-s-2.1-free',
|
|
36
|
+
'big-pickle',
|
|
37
|
+
'north-mini-code-free',
|
|
38
|
+
'longcat-2.0-free',
|
|
39
|
+
'ling-3.0-flash-free',
|
|
40
|
+
'ling-3.0-tiny-free',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const BRAND_NAMES = new Map([
|
|
44
|
+
['deepseek', 'DeepSeek'],
|
|
45
|
+
['gpt', 'GPT'],
|
|
46
|
+
['mimo', 'MiMo'],
|
|
47
|
+
['nemotron', 'Nemotron'],
|
|
48
|
+
['hy3', 'Hy3'],
|
|
49
|
+
['laguna', 'Laguna'],
|
|
50
|
+
['big', 'Big'],
|
|
51
|
+
['pickle', 'Pickle'],
|
|
52
|
+
['north', 'North'],
|
|
53
|
+
['mini', 'Mini'],
|
|
54
|
+
['code', 'Code'],
|
|
55
|
+
['longcat', 'LongCat'],
|
|
56
|
+
['ling', 'Ling'],
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
function isFreeModel(id) {
|
|
60
|
+
return id.endsWith('-free') || KNOWN_FREE_MODELS.has(id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function classify(id) {
|
|
64
|
+
for (const prefix of RESPONSES_PREFIXES) {
|
|
65
|
+
if (id.startsWith(prefix)) return { route: 'responses', guessed: false };
|
|
66
|
+
}
|
|
67
|
+
return { route: 'chat', guessed: false };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function modelReference(id, route) {
|
|
71
|
+
if (route === 'responses') return `openai/responses/${id}`;
|
|
72
|
+
return `openai/${id}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function labelFor(id) {
|
|
76
|
+
return id
|
|
77
|
+
.split('-')
|
|
78
|
+
.map((segment) => BRAND_NAMES.get(segment) ?? segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
79
|
+
.join(' ');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function usage() {
|
|
83
|
+
process.stderr.write(
|
|
84
|
+
'Usage: node scripts/discover-opencode-zen.mjs [--base-url <url>] [--write <config.json>] [--set-default] [--free-only]\n',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function main() {
|
|
89
|
+
const args = process.argv.slice(2);
|
|
90
|
+
let baseUrl = DEFAULT_BASE_URL;
|
|
91
|
+
let writePath = null;
|
|
92
|
+
let setDefault = false;
|
|
93
|
+
let freeOnly = false;
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
96
|
+
const arg = args[i];
|
|
97
|
+
if (arg === '--base-url') {
|
|
98
|
+
baseUrl = args[i + 1];
|
|
99
|
+
if (!baseUrl) throw new Error('--base-url requires a URL.');
|
|
100
|
+
i += 1;
|
|
101
|
+
} else if (arg === '--write') {
|
|
102
|
+
writePath = args[i + 1];
|
|
103
|
+
if (!writePath) throw new Error('--write requires a config.json path.');
|
|
104
|
+
i += 1;
|
|
105
|
+
} else if (arg === '--set-default') {
|
|
106
|
+
setDefault = true;
|
|
107
|
+
} else if (arg === '--free-only') {
|
|
108
|
+
freeOnly = true;
|
|
109
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
110
|
+
usage();
|
|
111
|
+
return;
|
|
112
|
+
} else {
|
|
113
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!baseUrl.endsWith('/v1')) {
|
|
118
|
+
process.stderr.write(`Warning: --base-url ${baseUrl} does not end in /v1; chat and responses URLs may be wrong.\n`);
|
|
119
|
+
}
|
|
120
|
+
const modelsUrl = `${baseUrl.replace(/\/$/, '')}/models`;
|
|
121
|
+
|
|
122
|
+
let response;
|
|
123
|
+
try {
|
|
124
|
+
response = await fetch(modelsUrl, { headers: { accept: 'application/json' } });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new Error(`Could not reach ${modelsUrl}: ${error.message}`);
|
|
127
|
+
}
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
throw new Error(`GET ${modelsUrl} returned HTTP ${response.status}.`);
|
|
130
|
+
}
|
|
131
|
+
const payload = await response.json();
|
|
132
|
+
const ids = (payload.data ?? [])
|
|
133
|
+
.map((entry) => entry.id)
|
|
134
|
+
.filter((id) => typeof id === 'string' && id);
|
|
135
|
+
|
|
136
|
+
const filteredIds = freeOnly ? ids.filter(isFreeModel) : ids;
|
|
137
|
+
|
|
138
|
+
const groups = { chat: [], responses: [] };
|
|
139
|
+
for (const id of filteredIds) {
|
|
140
|
+
const { route } = classify(id);
|
|
141
|
+
if (!(route in groups)) continue;
|
|
142
|
+
groups[route].push(id);
|
|
143
|
+
}
|
|
144
|
+
for (const route of Object.keys(groups)) groups[route].sort();
|
|
145
|
+
|
|
146
|
+
const chatProfileModels = {};
|
|
147
|
+
for (const id of groups.chat) {
|
|
148
|
+
chatProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'chat') };
|
|
149
|
+
}
|
|
150
|
+
const responsesProfileModels = {};
|
|
151
|
+
for (const id of groups.responses) {
|
|
152
|
+
responsesProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'responses') };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const chatDefault = groups.chat[0];
|
|
156
|
+
const responsesDefault = groups.responses[0];
|
|
157
|
+
|
|
158
|
+
const profiles = {};
|
|
159
|
+
if (groups.chat.length) {
|
|
160
|
+
profiles['opencode-zen'] = {
|
|
161
|
+
label: 'OpenCode Zen',
|
|
162
|
+
...(chatDefault ? { defaultModel: chatDefault } : {}),
|
|
163
|
+
models: chatProfileModels,
|
|
164
|
+
apiKeyEnv: API_KEY_ENV,
|
|
165
|
+
apiBaseEnv: API_BASE_ENV,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (groups.responses.length) {
|
|
169
|
+
profiles['opencode-zen-responses'] = {
|
|
170
|
+
label: 'OpenCode Zen (Responses)',
|
|
171
|
+
...(responsesDefault ? { defaultModel: responsesDefault } : {}),
|
|
172
|
+
models: responsesProfileModels,
|
|
173
|
+
apiKeyEnv: API_KEY_ENV,
|
|
174
|
+
apiBaseEnv: RESPONSES_BASE_ENV,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const consoleSection = { defaultProfile: 'opencode-zen', profiles };
|
|
179
|
+
|
|
180
|
+
process.stderr.write(
|
|
181
|
+
`Discovered ${ids.length} models (${groups.chat.length} chat, ${groups.responses.length} responses).` +
|
|
182
|
+
(freeOnly ? ` Filtered to ${filteredIds.length} free models.` : '') + '\n',
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (writePath) {
|
|
186
|
+
let root = {};
|
|
187
|
+
try {
|
|
188
|
+
root = JSON.parse(await readFile(writePath, 'utf8'));
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error.code !== 'ENOENT') throw new Error(`Could not read ${writePath}: ${error.message}`);
|
|
191
|
+
}
|
|
192
|
+
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
|
|
193
|
+
throw new Error(`${writePath} must contain a JSON object.`);
|
|
194
|
+
}
|
|
195
|
+
const existingConsole = typeof root.console === 'object' && root.console !== null ? root.console : {};
|
|
196
|
+
const mergedProfiles = { ...(existingConsole.profiles ?? {}), ...profiles };
|
|
197
|
+
const defaultProfile =
|
|
198
|
+
existingConsole.defaultProfile && !setDefault ? existingConsole.defaultProfile : 'opencode-zen';
|
|
199
|
+
root.console = { ...existingConsole, defaultProfile, profiles: mergedProfiles };
|
|
200
|
+
await writeFile(writePath, `${JSON.stringify(root, null, 2)}\n`);
|
|
201
|
+
process.stderr.write(`Merged profiles into ${writePath} (defaultProfile: ${defaultProfile}).\n`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
`Export the key and base URLs, then add the JSON below under "console" in config.json:\n` +
|
|
207
|
+
` export ${API_KEY_ENV}='sk-…'\n` +
|
|
208
|
+
` export ${API_BASE_ENV}='${baseUrl}'\n` +
|
|
209
|
+
` export ${RESPONSES_BASE_ENV}='${baseUrl}'\n` +
|
|
210
|
+
`(or rerun with --write ~/.catence/config.json to merge into an existing config.)\n`,
|
|
211
|
+
);
|
|
212
|
+
process.stdout.write(`${JSON.stringify(consoleSection, null, 2)}\n`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await main().catch((error) => {
|
|
216
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
});
|