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
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# Console LLM providers
|
|
2
|
+
|
|
3
|
+
Catence Console talks to a model through [LiteLLM](https://docs.litellm.ai/).
|
|
4
|
+
There are exactly three provider shapes: **OpenAI**, **Anthropic**, and a
|
|
5
|
+
generic **OpenAI-compatible** profile. Azure is not a special case in the code:
|
|
6
|
+
it is reached through the OpenAI-compatible profile. OpenCode Go can be reached
|
|
7
|
+
the same way through a local gateway, or directly at its own OpenAI-compatible
|
|
8
|
+
endpoint with native `openai/…` and `anthropic/…` model names; the steps for
|
|
9
|
+
each are documented below.
|
|
10
|
+
|
|
11
|
+
## How configuration is split
|
|
12
|
+
|
|
13
|
+
Catence never stores a credential value in `config.json`. A Console profile
|
|
14
|
+
records only a LiteLLM model name and the *names* of the environment variables
|
|
15
|
+
that hold the key, base URL, and (optionally) API version. The values
|
|
16
|
+
themselves stay in the Console process environment.
|
|
17
|
+
|
|
18
|
+
```jsonc
|
|
19
|
+
// ~/.catence/config.json (or /data/config.json inside the Docker image)
|
|
20
|
+
{
|
|
21
|
+
"console": {
|
|
22
|
+
"defaultProfile": "openai",
|
|
23
|
+
"profiles": {
|
|
24
|
+
"openai": {
|
|
25
|
+
"label": "OpenAI",
|
|
26
|
+
"model": "openai/gpt-5-mini",
|
|
27
|
+
"apiKeyEnv": "OPENAI_API_KEY"
|
|
28
|
+
},
|
|
29
|
+
"anthropic": {
|
|
30
|
+
"label": "Anthropic",
|
|
31
|
+
"model": "anthropic/claude-sonnet-4-5",
|
|
32
|
+
"apiKeyEnv": "ANTHROPIC_API_KEY"
|
|
33
|
+
},
|
|
34
|
+
"openai-compatible": {
|
|
35
|
+
"label": "OpenAI-compatible",
|
|
36
|
+
"model": "openai/your-model-name",
|
|
37
|
+
"apiKeyEnv": "OPENAI_API_KEY",
|
|
38
|
+
"apiBaseEnv": "OPENAI_API_BASE"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The supported profile fields are `label`, `model` (or `models` + `defaultModel`
|
|
46
|
+
for multiple deployments), `defaultReasoningEffort`, `apiKeyEnv`, `apiBaseEnv`,
|
|
47
|
+
and `apiVersionEnv`. Every `*Env` value must be an uppercase environment
|
|
48
|
+
variable name; a raw secret there is rejected.
|
|
49
|
+
|
|
50
|
+
| Profile field | Environment variable it reads |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `apiKeyEnv` | API key |
|
|
53
|
+
| `apiBaseEnv` | Base URL (OpenAI-compatible endpoints) |
|
|
54
|
+
| `apiVersionEnv` | Optional API version, only for endpoints that require one |
|
|
55
|
+
|
|
56
|
+
## OpenAI
|
|
57
|
+
|
|
58
|
+
Point the profile at the public OpenAI API. Only a key is required.
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
export OPENAI_API_KEY='sk-…'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```jsonc
|
|
65
|
+
{
|
|
66
|
+
"console": {
|
|
67
|
+
"defaultProfile": "openai",
|
|
68
|
+
"profiles": {
|
|
69
|
+
"openai": {
|
|
70
|
+
"label": "OpenAI",
|
|
71
|
+
"model": "openai/gpt-5-mini",
|
|
72
|
+
"apiKeyEnv": "OPENAI_API_KEY"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Anthropic
|
|
80
|
+
|
|
81
|
+
Only a key is required.
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
export ANTHROPIC_API_KEY='sk-ant-…'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```jsonc
|
|
88
|
+
{
|
|
89
|
+
"console": {
|
|
90
|
+
"defaultProfile": "anthropic",
|
|
91
|
+
"profiles": {
|
|
92
|
+
"anthropic": {
|
|
93
|
+
"label": "Anthropic",
|
|
94
|
+
"model": "anthropic/claude-sonnet-4-5",
|
|
95
|
+
"apiKeyEnv": "ANTHROPIC_API_KEY"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## OpenAI-compatible
|
|
103
|
+
|
|
104
|
+
Use this one profile for any endpoint that speaks the OpenAI Chat Completions
|
|
105
|
+
API: Azure, Opencode, Ollama, LM Studio, a LiteLLM proxy, and so on. Set the
|
|
106
|
+
key and the base URL; the model name is whatever the endpoint expects.
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
export OPENAI_API_KEY='…'
|
|
110
|
+
export OPENAI_API_BASE='https://host.example/v1'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```jsonc
|
|
114
|
+
{
|
|
115
|
+
"console": {
|
|
116
|
+
"defaultProfile": "openai-compatible",
|
|
117
|
+
"profiles": {
|
|
118
|
+
"openai-compatible": {
|
|
119
|
+
"label": "OpenAI-compatible",
|
|
120
|
+
"model": "openai/your-model-name",
|
|
121
|
+
"apiKeyEnv": "OPENAI_API_KEY",
|
|
122
|
+
"apiBaseEnv": "OPENAI_API_BASE"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
LiteLLM appends `/chat/completions` to `OPENAI_API_BASE`, so point it at the
|
|
130
|
+
`…/v1` root of the endpoint, not the full completions path.
|
|
131
|
+
|
|
132
|
+
### Azure
|
|
133
|
+
|
|
134
|
+
Azure OpenAI's unified v1 API is OpenAI-compatible, so it uses the same
|
|
135
|
+
`openai-compatible` profile. Use the **Azure deployment name** as the model.
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
export OPENAI_API_KEY='your-azure-openai-api-key'
|
|
139
|
+
export OPENAI_API_BASE='https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```jsonc
|
|
143
|
+
{
|
|
144
|
+
"console": {
|
|
145
|
+
"defaultProfile": "openai-compatible",
|
|
146
|
+
"profiles": {
|
|
147
|
+
"openai-compatible": {
|
|
148
|
+
"label": "Azure OpenAI",
|
|
149
|
+
"model": "openai/gpt-4.1-nano", // your deployment name
|
|
150
|
+
"apiKeyEnv": "OPENAI_API_KEY",
|
|
151
|
+
"apiBaseEnv": "OPENAI_API_BASE"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Notes:
|
|
159
|
+
|
|
160
|
+
- `OPENAI_API_BASE` also accepts the Foundry models form
|
|
161
|
+
`https://YOUR-RESOURCE-NAME.services.ai.azure.com/openai/v1/`.
|
|
162
|
+
- With the v1 API the `api-version` query parameter is no longer required, so
|
|
163
|
+
`apiVersionEnv` stays unset. If an older endpoint still needs one, add
|
|
164
|
+
`"apiVersionEnv": "OPENAI_API_VERSION"` and export
|
|
165
|
+
`OPENAI_API_VERSION='2024-06-01'` (or your API version).
|
|
166
|
+
- `OPENAI_API_KEY` holds the Azure OpenAI API key. If you use Microsoft Entra
|
|
167
|
+
ID instead, a keyless token provider can be passed in the same variable.
|
|
168
|
+
|
|
169
|
+
### Opencode
|
|
170
|
+
|
|
171
|
+
[OpenCode Go](https://opencode.ai) publishes an OpenAI-compatible API at
|
|
172
|
+
`https://opencode.ai/zen/go/v1`; its `GET /v1/models` listing is public and does
|
|
173
|
+
not require a key. The Console can use it directly, so most setups do not need a
|
|
174
|
+
local gateway.
|
|
175
|
+
|
|
176
|
+
The repository includes a discovery script that fetches the live model list and
|
|
177
|
+
merges ready-made Console profiles into `config.json`. From a Catence checkout:
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
npm run discover:opencode-go -- --write ~/.catence/config.json
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The script adds two profiles, because OpenCode Go routes model families through
|
|
184
|
+
different API shapes:
|
|
185
|
+
|
|
186
|
+
- `opencode-go` — chat and responses models, base
|
|
187
|
+
`https://opencode.ai/zen/go/v1` (LiteLLM `openai/…` names).
|
|
188
|
+
- `opencode-go-messages` — messages models, base `https://opencode.ai/zen/go`
|
|
189
|
+
(LiteLLM `anthropic/…` names).
|
|
190
|
+
|
|
191
|
+
| Family | Profile prefix | Example |
|
|
192
|
+
| --- | --- | --- |
|
|
193
|
+
| Chat | `openai/<id>` | `openai/deepseek-v4-flash` |
|
|
194
|
+
| Responses | `openai/responses/<id>` | `openai/responses/grok-4.5` |
|
|
195
|
+
| Messages | `anthropic/<id>` | `anthropic/minimax-m2.5` |
|
|
196
|
+
|
|
197
|
+
The script warns on stderr and skips any model that does not fit one of these
|
|
198
|
+
families. Rerun it to refresh the model lists; existing profiles, limits, and
|
|
199
|
+
`defaultProfile` are preserved unless `--set-default` is passed.
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
export OPENCODE_GO_API_KEY='…' # any non-empty value satisfies the key check
|
|
203
|
+
export OPENCODE_GO_API_BASE='https://opencode.ai/zen/go/v1'
|
|
204
|
+
export OPENCODE_GO_MESSAGES_API_BASE='https://opencode.ai/zen/go'
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```jsonc
|
|
208
|
+
{
|
|
209
|
+
"console": {
|
|
210
|
+
"defaultProfile": "opencode-go",
|
|
211
|
+
"profiles": {
|
|
212
|
+
"opencode-go": {
|
|
213
|
+
"label": "OpenCode Go",
|
|
214
|
+
"defaultModel": "flash",
|
|
215
|
+
"models": {
|
|
216
|
+
"flash": { "label": "DeepSeek V4 flash", "model": "openai/deepseek-v4-flash" },
|
|
217
|
+
"grok": { "label": "Grok 4.5", "model": "openai/responses/grok-4.5" }
|
|
218
|
+
},
|
|
219
|
+
"apiKeyEnv": "OPENCODE_GO_API_KEY",
|
|
220
|
+
"apiBaseEnv": "OPENCODE_GO_API_BASE"
|
|
221
|
+
},
|
|
222
|
+
"opencode-go-messages": {
|
|
223
|
+
"label": "OpenCode Go messages",
|
|
224
|
+
"defaultModel": "minimax",
|
|
225
|
+
"models": {
|
|
226
|
+
"minimax": { "label": "MiniMax M2.5", "model": "anthropic/minimax-m2.5" }
|
|
227
|
+
},
|
|
228
|
+
"apiKeyEnv": "OPENCODE_GO_API_KEY",
|
|
229
|
+
"apiBaseEnv": "OPENCODE_GO_MESSAGES_API_BASE"
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Verify with `catence-console doctor`. The `responses` models
|
|
237
|
+
(`openai/responses/…`) go through LiteLLM's responses bridge and are the least
|
|
238
|
+
battle-tested path; smoke-test one chat turn before relying on them.
|
|
239
|
+
|
|
240
|
+
If you instead want to reuse the providers already configured in your local
|
|
241
|
+
Opencode CLI, run an OpenAI-compatible gateway such as the
|
|
242
|
+
[`opencode-llm-proxy`](https://github.com/KochC/opencode-llm-proxy) plugin, which
|
|
243
|
+
serves Chat Completions at `http://127.0.0.1:4010/v1` and lists every configured
|
|
244
|
+
model as `provider/model`.
|
|
245
|
+
|
|
246
|
+
```sh
|
|
247
|
+
export OPENAI_API_KEY='unused' # or your OPENCODE_LLM_PROXY_TOKEN
|
|
248
|
+
export OPENAI_API_BASE='http://127.0.0.1:4010/v1'
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```jsonc
|
|
252
|
+
{
|
|
253
|
+
"console": {
|
|
254
|
+
"defaultProfile": "openai-compatible",
|
|
255
|
+
"profiles": {
|
|
256
|
+
"openai-compatible": {
|
|
257
|
+
"label": "Opencode",
|
|
258
|
+
"model": "openai/github-copilot/claude-sonnet-4.6", // provider/model
|
|
259
|
+
"apiKeyEnv": "OPENAI_API_KEY",
|
|
260
|
+
"apiBaseEnv": "OPENAI_API_BASE"
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Notes:
|
|
268
|
+
|
|
269
|
+
- Use the fully qualified `provider/model` id from the gateway's
|
|
270
|
+
`GET /v1/models` listing.
|
|
271
|
+
- If the gateway requires a bearer token, set `OPENAI_API_KEY` to the
|
|
272
|
+
`OPENCODE_LLM_PROXY_TOKEN`; otherwise any non-empty value satisfies the
|
|
273
|
+
profile's required-environment check.
|
|
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
|
+
|
|
352
|
+
## Multiple deployments per provider
|
|
353
|
+
|
|
354
|
+
A profile can list several models under one set of credentials; the Console
|
|
355
|
+
settings then offer a per-deployment choice.
|
|
356
|
+
|
|
357
|
+
```jsonc
|
|
358
|
+
{
|
|
359
|
+
"console": {
|
|
360
|
+
"profiles": {
|
|
361
|
+
"openai-compatible": {
|
|
362
|
+
"label": "Azure OpenAI",
|
|
363
|
+
"defaultModel": "nano",
|
|
364
|
+
"models": {
|
|
365
|
+
"nano": { "label": "GPT-4.1 nano", "model": "openai/gpt-4.1-nano" },
|
|
366
|
+
"mini": { "label": "GPT-5 mini", "model": "openai/gpt-5-mini" }
|
|
367
|
+
},
|
|
368
|
+
"apiKeyEnv": "OPENAI_API_KEY",
|
|
369
|
+
"apiBaseEnv": "OPENAI_API_BASE"
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Reasoning effort
|
|
377
|
+
|
|
378
|
+
Models expose a per-chat **Thinking effort** dropdown in the Console settings.
|
|
379
|
+
By default it offers the OpenAI-standard levels — `minimal`, `low`, `medium`,
|
|
380
|
+
`high`, `xhigh` — which suit OpenAI and most OpenAI-compatible endpoints.
|
|
381
|
+
|
|
382
|
+
Some models (for example DeepSeek V4 Flash) use their own reasoning-effort
|
|
383
|
+
scheme rather than the OpenAI-standard list. For those, declare `variants` on
|
|
384
|
+
the model: a mapping of dropdown label to the exact value sent to the provider.
|
|
385
|
+
The chat dropdown then shows `Provider default` plus each label instead of the
|
|
386
|
+
OpenAI-standard list.
|
|
387
|
+
|
|
388
|
+
```jsonc
|
|
389
|
+
{
|
|
390
|
+
"console": {
|
|
391
|
+
"profiles": {
|
|
392
|
+
"opencode-zen": {
|
|
393
|
+
"label": "OpenCode Zen",
|
|
394
|
+
"defaultModel": "deepseek-v4-flash-free",
|
|
395
|
+
"models": {
|
|
396
|
+
"deepseek-v4-flash-free": {
|
|
397
|
+
"label": "DeepSeek V4 Flash Free",
|
|
398
|
+
"model": "openai/deepseek-v4-flash-free",
|
|
399
|
+
"variants": { "High": "high", "Max": "max" }
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
403
|
+
"apiBaseEnv": "OPENCODE_ZEN_API_BASE"
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Selecting a variant sends that value verbatim as `reasoning_effort` (with
|
|
411
|
+
`allowed_openai_params` so LiteLLM forwards it for OpenAI-compatible models).
|
|
412
|
+
The `Provider default` option sends no reasoning effort at all.
|
|
413
|
+
|
|
414
|
+
An explicitly empty `variants` map disables reasoning effort for that model:
|
|
415
|
+
no `reasoning_effort` is sent (the agent guards against it) and the chat
|
|
416
|
+
**Thinking effort** dropdown is disabled in the UI. This suits models that
|
|
417
|
+
reject the parameter, such as MiMo, which expects its own `thinking` toggle
|
|
418
|
+
instead. `variants: {}` cannot be combined with a fixed `reasoningEffort` on
|
|
419
|
+
the same model.
|
|
420
|
+
|
|
421
|
+
You can also pin a fixed level with `reasoningEffort` on the model, or a
|
|
422
|
+
profile-wide default with `defaultReasoningEffort` (OpenAI-standard values
|
|
423
|
+
only). Precedence: per-chat dropdown selection > model `reasoningEffort` >
|
|
424
|
+
profile `defaultReasoningEffort` > provider default. The **Thinking effort**
|
|
425
|
+
dropdown is rebuilt whenever the **Model** dropdown changes, so its options
|
|
426
|
+
(and disabled state) always reflect the selected model.
|
|
427
|
+
|
|
428
|
+
## Verifying a profile
|
|
429
|
+
|
|
430
|
+
`catence-console doctor` reports, for each profile, which environment variables
|
|
431
|
+
are still missing and whether the local Catence runtime is reachable. It never
|
|
432
|
+
prints credential values.
|
|
433
|
+
|
|
434
|
+
```sh
|
|
435
|
+
catence-console doctor --home "$HOME/.catence" --mcp-url http://127.0.0.1:8787/mcp
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
On Docker, the deploy scaffold includes a helper that runs it inside the
|
|
439
|
+
running container (`exec`, not `run` — the container's loopback hosts the live
|
|
440
|
+
MCP server and the `.env` provider keys are in its environment):
|
|
441
|
+
|
|
442
|
+
```sh
|
|
443
|
+
./catence-deploy/doctor.sh
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
## Docker: generating the Console login hash
|
|
447
|
+
|
|
448
|
+
`deploy-console.sh` writes a small helper into the deployment directory. Run it
|
|
449
|
+
once after the script to generate the bcrypt hash for
|
|
450
|
+
`CATENCE_CONSOLE_PASSWORD_HASH` — it prompts for the password on the terminal,
|
|
451
|
+
never echoes it, and uses the compose project so it works no matter what image
|
|
452
|
+
tag the script built:
|
|
453
|
+
|
|
454
|
+
```sh
|
|
455
|
+
./catence-deploy/hash-password.sh
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Paste the output into `CATENCE_CONSOLE_PASSWORD_HASH` in `catence-deploy/.env`,
|
|
459
|
+
then start the stack (`./deploy-console.sh beta` again, or
|
|
460
|
+
`docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env up -d`).
|
|
461
|
+
|
|
462
|
+
If you prefer, re-run the deploy script with `--generate-secrets` instead and
|
|
463
|
+
answer the interactive password prompt; it saves the hash to `.env` for you.
|
|
464
|
+
Without the helper (for example an older deployment), the equivalent command
|
|
465
|
+
is `docker run --rm -it --entrypoint /opt/catence-console/bin/catence-console
|
|
466
|
+
<image-tag> auth hash-password`, where `<image-tag>` is the tag printed by the
|
|
467
|
+
script.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "catence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-beta.10",
|
|
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": {
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"README.md",
|
|
29
29
|
"config.example.json",
|
|
30
30
|
"docs",
|
|
31
|
+
"scripts/discover-opencode-go.mjs",
|
|
32
|
+
"scripts/discover-opencode-zen.mjs",
|
|
31
33
|
"pyproject.toml",
|
|
32
34
|
"uv.lock",
|
|
33
35
|
"python/__init__.py",
|
|
@@ -55,6 +57,8 @@
|
|
|
55
57
|
"mcp": "tsx src/interfaces/mcp/main.ts",
|
|
56
58
|
"console": "uv run --project console catence-console serve",
|
|
57
59
|
"console:doctor": "uv run --project console catence-console doctor",
|
|
60
|
+
"discover:opencode-go": "node scripts/discover-opencode-go.mjs",
|
|
61
|
+
"discover:opencode-zen": "node scripts/discover-opencode-zen.mjs",
|
|
58
62
|
"package:check": "npm pack --dry-run",
|
|
59
63
|
"release:check": "node scripts/release-artifacts.mjs check",
|
|
60
64
|
"mcpb:validate": "npm exec --yes --package @anthropic-ai/mcpb -- mcpb validate mcpb/manifest.json",
|
|
@@ -80,6 +84,6 @@
|
|
|
80
84
|
"overrides": {
|
|
81
85
|
"fast-uri": "3.1.5",
|
|
82
86
|
"hono": "4.12.34",
|
|
83
|
-
"nanoid": "3.3.
|
|
87
|
+
"nanoid": "3.3.18"
|
|
84
88
|
}
|
|
85
89
|
}
|
|
@@ -4,11 +4,14 @@ import argparse
|
|
|
4
4
|
import getpass
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
7
9
|
from datetime import date
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
|
|
12
|
+
from .progress import ProgressReporter
|
|
10
13
|
from .staging import StagingWriter
|
|
11
|
-
from .worker import GarminStagingWorker
|
|
14
|
+
from .worker import GarminStagingWorker, WorkerInterrupted
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
def parse_args() -> argparse.Namespace:
|
|
@@ -43,19 +46,37 @@ def main() -> None:
|
|
|
43
46
|
token_store.parent.mkdir(parents=True, exist_ok=True)
|
|
44
47
|
client = Garmin(email, password, prompt_mfa=lambda: input("Garmin MFA code: "))
|
|
45
48
|
client.login(str(token_store))
|
|
49
|
+
reporter = ProgressReporter(args.run_id)
|
|
50
|
+
reporter.set_stage("login")
|
|
51
|
+
reporter.finish("login")
|
|
46
52
|
known_hashes: dict[str, str] = {}
|
|
47
53
|
if args.known_activities and args.known_activities.exists():
|
|
48
54
|
loaded = json.loads(args.known_activities.read_text(encoding="utf-8"))
|
|
49
55
|
if isinstance(loaded, dict):
|
|
50
56
|
known_hashes = {str(key): str(value) for key, value in loaded.items()}
|
|
51
|
-
GarminStagingWorker(client, writer, args.data_dir, known_hashes)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
worker = GarminStagingWorker(client, writer, args.data_dir, known_hashes, progress=reporter)
|
|
58
|
+
interrupt_signal: int | None = None
|
|
59
|
+
|
|
60
|
+
def _request_interrupt(signum: int, _frame: object) -> None:
|
|
61
|
+
nonlocal interrupt_signal
|
|
62
|
+
interrupt_signal = signum
|
|
63
|
+
worker.request_interrupt()
|
|
64
|
+
|
|
65
|
+
signal.signal(signal.SIGINT, _request_interrupt)
|
|
66
|
+
signal.signal(signal.SIGTERM, _request_interrupt)
|
|
67
|
+
try:
|
|
68
|
+
worker.sync(
|
|
69
|
+
daily_from_date=None if args.skip_daily else args.daily_from_date or args.from_date,
|
|
70
|
+
activity_from_date=None if args.skip_activities else args.activity_from_date or args.from_date,
|
|
71
|
+
to_date=args.to_date,
|
|
72
|
+
daily_to_date=args.daily_to_date,
|
|
73
|
+
activity_to_date=args.activity_to_date,
|
|
74
|
+
include_non_historical=not args.historical_only,
|
|
75
|
+
)
|
|
76
|
+
except WorkerInterrupted:
|
|
77
|
+
reporter.finish("interrupted")
|
|
78
|
+
raise SystemExit(130 if interrupt_signal == signal.SIGINT else 143)
|
|
79
|
+
reporter.finish("completed")
|
|
59
80
|
|
|
60
81
|
|
|
61
82
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Progress reporting for Garmin sync runs.
|
|
2
|
+
|
|
3
|
+
Emits single-line JSON progress records (kind: 'progress') on stdout so the
|
|
4
|
+
Node management layer can persist heartbeats without parsing the staging
|
|
5
|
+
JSONL consumed by the importer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProgressReporter:
|
|
17
|
+
"""Throttled progress publisher writing NDJSON records to stdout."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
run_id: str,
|
|
22
|
+
provider: str = "garmin",
|
|
23
|
+
emit: Callable[[str], None] = lambda line: print(line, flush=True),
|
|
24
|
+
interval_seconds: float = 2.0,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.run_id = run_id
|
|
27
|
+
self.provider = provider
|
|
28
|
+
self.emit = emit
|
|
29
|
+
self.interval_seconds = interval_seconds
|
|
30
|
+
self._started = time.monotonic()
|
|
31
|
+
self._last_emitted = 0.0
|
|
32
|
+
self.stage = "starting"
|
|
33
|
+
self.current_step: str | None = None
|
|
34
|
+
self.completed_units = 0
|
|
35
|
+
self.total_units: int | None = None
|
|
36
|
+
|
|
37
|
+
def set_stage(self, stage: str) -> None:
|
|
38
|
+
"""Switch phases, resetting phase-local counters and forcing a publish."""
|
|
39
|
+
if stage != self.stage:
|
|
40
|
+
self.stage = stage
|
|
41
|
+
self.current_step = None
|
|
42
|
+
self.completed_units = 0
|
|
43
|
+
self.total_units = None
|
|
44
|
+
self.publish(force=True)
|
|
45
|
+
|
|
46
|
+
def advance(self, completed: int | None = None, total: int | None = None, step: str | None = None) -> None:
|
|
47
|
+
if completed is not None:
|
|
48
|
+
self.completed_units = completed
|
|
49
|
+
if total is not None:
|
|
50
|
+
self.total_units = total
|
|
51
|
+
if step is not None:
|
|
52
|
+
self.current_step = step
|
|
53
|
+
self.publish()
|
|
54
|
+
|
|
55
|
+
def publish(self, force: bool = False) -> None:
|
|
56
|
+
now = time.monotonic()
|
|
57
|
+
if not force and now - self._last_emitted < self.interval_seconds:
|
|
58
|
+
return
|
|
59
|
+
self._last_emitted = now
|
|
60
|
+
elapsed = now - self._started
|
|
61
|
+
percent = 0.0
|
|
62
|
+
eta: float | None = None
|
|
63
|
+
if self.total_units and self.total_units > 0:
|
|
64
|
+
percent = min(100.0, max(0.0, self.completed_units / self.total_units * 100.0))
|
|
65
|
+
if self.completed_units > 0 and self.completed_units < self.total_units:
|
|
66
|
+
rate = elapsed / self.completed_units
|
|
67
|
+
eta = rate * (self.total_units - self.completed_units)
|
|
68
|
+
record = {
|
|
69
|
+
"kind": "progress",
|
|
70
|
+
"runId": self.run_id,
|
|
71
|
+
"provider": self.provider,
|
|
72
|
+
"stage": self.stage,
|
|
73
|
+
"currentStep": self.current_step,
|
|
74
|
+
"completedUnits": self.completed_units,
|
|
75
|
+
"totalUnits": self.total_units,
|
|
76
|
+
"percentComplete": round(percent, 2),
|
|
77
|
+
"elapsedSeconds": round(elapsed, 1),
|
|
78
|
+
"estimatedRemainingSeconds": round(eta, 1) if eta is not None else None,
|
|
79
|
+
"heartbeatAt": datetime.now(timezone.utc).isoformat(),
|
|
80
|
+
}
|
|
81
|
+
self.emit(json.dumps(record, separators=(",", ":")))
|
|
82
|
+
|
|
83
|
+
def finish(self, stage: str = "completed") -> None:
|
|
84
|
+
self.stage = stage
|
|
85
|
+
self.publish(force=True)
|