catence 0.2.0-beta.1 → 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.
Files changed (38) hide show
  1. package/README.md +144 -2
  2. package/config.example.json +32 -20
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +1 -0
  5. package/dist/contracts/progress.d.ts +23 -0
  6. package/dist/contracts/progress.js +48 -0
  7. package/dist/contracts/release.d.ts +1 -1
  8. package/dist/contracts/release.js +1 -1
  9. package/dist/core/logging.d.ts +16 -0
  10. package/dist/core/logging.js +68 -0
  11. package/dist/core/query/repository.d.ts +2 -0
  12. package/dist/core/query/repository.js +42 -0
  13. package/dist/core/runtime/catalog.js +17 -7
  14. package/dist/core/runtime/self-update.d.ts +44 -0
  15. package/dist/core/runtime/self-update.js +138 -0
  16. package/dist/elt/application/management.d.ts +2 -0
  17. package/dist/elt/application/management.js +194 -17
  18. package/dist/elt/ingestion/importer.d.ts +3 -2
  19. package/dist/elt/ingestion/importer.js +4 -3
  20. package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
  21. package/dist/elt/ingestion/providers/intervals.js +4 -1
  22. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  23. package/dist/elt/storage/database.d.ts +7 -2
  24. package/dist/elt/storage/database.js +81 -4
  25. package/dist/elt/storage/migrations.js +25 -1
  26. package/dist/interfaces/cli/commands.js +91 -1
  27. package/dist/interfaces/mcp/server.js +7 -0
  28. package/dist/runtime/index.d.ts +4 -1
  29. package/dist/runtime/index.js +2 -1
  30. package/docs/deployment.md +338 -0
  31. package/docs/docker-deployment.md +232 -0
  32. package/docs/llm-providers.md +467 -0
  33. package/package.json +5 -1
  34. package/python/catence/providers/garmin/cli.py +30 -9
  35. package/python/catence/providers/garmin/progress.py +85 -0
  36. package/python/catence/providers/garmin/worker.py +132 -5
  37. package/scripts/discover-opencode-go.mjs +210 -0
  38. package/scripts/discover-opencode-zen.mjs +218 -0
@@ -0,0 +1,232 @@
1
+ # Docker Deployment Quick Start and Common Commands
2
+
3
+ This document mirrors the [local quick start](../README.md#quick-start) and [common operations](../README.md#common-operations) for Catence when deployed via Docker. It assumes you have completed the [deployment steps](deployment.md#path-b--docker-deployment) and have a running `catence-deploy` stack.
4
+
5
+ ## Quick Start (Docker)
6
+
7
+ All commands run from the `catence-deploy` directory created by the deploy script.
8
+
9
+ ### 1. Initialize an athlete store
10
+
11
+ ```sh
12
+ docker compose -f docker-compose.yml --env-file .env run --rm \
13
+ --entrypoint catence-data console setup --athlete alex --label "Alex"
14
+ ```
15
+
16
+ ### 2. Store provider credentials (stdin only, never shell history)
17
+
18
+ ```sh
19
+ # Garmin
20
+ printf %s 'alex@example.com' | docker compose -f docker-compose.yml --env-file .env run --rm -T \
21
+ --entrypoint catence-data console --athlete alex secret set \
22
+ --provider garmin --field email --value-stdin
23
+ printf %s 'your-garmin-password' | docker compose -f docker-compose.yml --env-file .env run --rm -T \
24
+ --entrypoint catence-data console --athlete alex secret set \
25
+ --provider garmin --field password --value-stdin
26
+
27
+ # Intervals.icu
28
+ printf %s 'intervals-api-key' | docker compose -f docker-compose.yml --env-file .env run --rm -T \
29
+ --entrypoint catence-data console --athlete alex secret set \
30
+ --provider intervals --field apiKey --value-stdin
31
+ printf %s '12345' | docker compose -f docker-compose.yml --env-file .env run --rm -T \
32
+ --entrypoint catence-data console --athlete alex secret set \
33
+ --provider intervals --field athleteId --value-stdin
34
+
35
+ # Strava (create an API application at https://www.strava.com/settings/api)
36
+ printf %s 'strava-client-id' | docker compose -f docker-compose.yml --env-file .env run --rm -T \
37
+ --entrypoint catence-data console --athlete alex secret set \
38
+ --provider strava --field clientId --value-stdin
39
+ printf %s 'strava-client-secret' | docker compose -f docker-compose.yml --env-file .env run --rm -T \
40
+ --entrypoint catence-data console --athlete alex secret set \
41
+ --provider strava --field clientSecret --value-stdin
42
+ ```
43
+
44
+ ### 3. Sync data and build retrieval context
45
+
46
+ ```sh
47
+ docker compose -f docker-compose.yml --env-file .env exec console \
48
+ catence-data sync --athlete alex --provider all --home /data
49
+ docker compose -f docker-compose.yml --env-file .env exec console \
50
+ catence-data build-retrieval-index --athlete alex --home /data
51
+ ```
52
+
53
+ ### 4. Access the Console
54
+
55
+ Open `http://127.0.0.1:8000` (or the `--bind:--port` you configured) and log in with `CATENCE_CONSOLE_USERNAME` and the password whose hash is in `CATENCE_CONSOLE_PASSWORD_HASH`.
56
+
57
+ ---
58
+
59
+ ## Common Operations (Docker)
60
+
61
+ The `sync.sh` helper in `catence-deploy/` wraps the most frequent `catence-data` calls inside the **running** container (so the provider secrets from the data volume are already available).
62
+
63
+ ```sh
64
+ # Incremental sync for one athlete (all providers)
65
+ ./sync.sh --athlete alex sync
66
+
67
+ # Sync a specific provider
68
+ ./sync.sh --athlete alex sync --provider intervals
69
+
70
+ # Sync from a specific date (Garmin)
71
+ ./sync.sh --athlete alex sync --provider garmin --from 2025-07-29
72
+
73
+ # Full backfill from a date
74
+ ./sync.sh --athlete alex backfill 2026-01-01
75
+
76
+ # Backfill with refresh (re-fetch existing data)
77
+ ./sync.sh --athlete alex backfill 2026-01-01 --refresh
78
+
79
+ # Retry a failed sync run
80
+ ./sync.sh --athlete alex retry --run <run-id>
81
+
82
+ # Coverage and error status
83
+ ./sync.sh --athlete alex status
84
+
85
+ # List athlete IDs in the catalog
86
+ ./sync.sh athletes
87
+
88
+ # Strava OAuth (uses clientId/clientSecret stored for this athlete)
89
+ docker compose -f docker-compose.yml --env-file .env exec console \
90
+ catence-data auth strava --athlete alex --callback --home /data
91
+ docker compose -f docker-compose.yml --env-file .env exec console \
92
+ catence-data disconnect strava --athlete alex --home /data
93
+
94
+ # Strava OAuth via SSH (remote Docker host accessed over SSH/Tailscale)
95
+ ## Option 1: SSH Port Forwarding (recommended)
96
+ # On your LOCAL machine, forward the callback port to the remote Docker host:
97
+ ssh -L 8765:127.0.0.1:8765 user@docker-host
98
+ # Then run the standard callback flow on the REMOTE host (uses http://127.0.0.1:8765/strava/callback)
99
+ docker compose -f docker-compose.yml --env-file .env exec console \
100
+ catence-data auth strava --athlete alex --callback --home /data
101
+
102
+ ## Option 2: Manual code exchange with http://localhost
103
+ # On the REMOTE host, get the authorization URL:
104
+ docker compose -f docker-compose.yml --env-file .env run --rm \
105
+ --entrypoint catence-data console auth strava \
106
+ --athlete alex --redirect-uri "http://localhost" --home /data
107
+ # Open the printed URL in YOUR LOCAL BROWSER, approve Strava, then copy the 'code' from the redirect URL
108
+ # Exchange the code on the REMOTE host:
109
+ docker compose -f docker-compose.yml --env-file .env run --rm \
110
+ --entrypoint catence-data console auth strava \
111
+ --athlete alex --code "THE_CODE_FROM_URL" --redirect-uri "http://localhost" --home /data
112
+
113
+ # Update the runtime and Console (re-run deploy script on the same channel)
114
+ curl -fsSL https://raw.githubusercontent.com/rifusaki/catence/beta/scripts/deploy-console.sh | bash -s beta
115
+ # or from checkout: ./scripts/deploy-console.sh beta
116
+
117
+ # Choose a non-default catalog location (bind mount instead of named volume)
118
+ # Stop the stack, edit docker-compose.yml to replace the volume with:
119
+ # volumes:
120
+ # - /srv/catence:/data
121
+ # Then restart and run the same commands (--home /data is implied by CATENCE_HOME).
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Connecting to the Docker-based MCP from Outside
127
+
128
+ ### The MCP Server Inside the Container
129
+
130
+ By default, the Catence MCP server runs on **loopback only** inside the container at `http://127.0.0.1:8787/mcp`. It is **never exposed** to the host network or the internet — this is intentional for security.
131
+
132
+ The `CATENCE_MCP_BIND` variable controls **two things at once**:
133
+
134
+ 1. **Docker port mapping**: which host interface exposes port 8787 to the Docker host.
135
+ 2. **Server listen address**: which address the MCP server binds to inside the container.
136
+
137
+ | Value | Docker maps | Server binds | Effect |
138
+ |-------|-------------|--------------|--------|
139
+ | `127.0.0.1` (default) | `127.0.0.1:8787 → container:8787` | `127.0.0.1` | MCP reachable only from the Docker host's loopback — requires SSH tunnel for remote access. |
140
+ | `0.0.0.0` | `0.0.0.0:8787 → container:8787` | `0.0.0.0` | MCP reachable from any network interface — suitable for Tailscale or VPN exposure. |
141
+
142
+ Set it at deploy time with `--mcp-bind`, or by editing `CATENCE_MCP_BIND` in `.env`:
143
+
144
+ ```sh
145
+ # Default (loopback only — SSH tunnel required for remote access)
146
+ ./scripts/deploy-console.sh beta
147
+
148
+ # Tailscale / network exposure
149
+ ./scripts/deploy-console.sh beta --mcp-bind 0.0.0.0
150
+ ```
151
+
152
+ To change the binding after deployment, update `CATENCE_MCP_BIND` in `.env` and restart:
153
+
154
+ ```sh
155
+ sed -i 's/CATENCE_MCP_BIND=.*/CATENCE_MCP_BIND=0.0.0.0/' catence-deploy/.env
156
+ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env up -d
157
+ ```
158
+
159
+ ### SSH Port Forwarding (Recommended for Remote MCP Clients)
160
+
161
+ From your **local machine**, forward the container's MCP port through the Docker host:
162
+
163
+ ```sh
164
+ # Forward local port 8787 → Docker host port 8787 → container loopback 8787
165
+ ssh -L 8787:127.0.0.1:8787 user@docker-host
166
+ ```
167
+
168
+ Then point your MCP client at `http://127.0.0.1:8787/mcp` on your local machine.
169
+
170
+ ### Exposing MCP on Tailscale (Alternative)
171
+
172
+ To expose the MCP server directly on a Tailscale interface (or any network interface) without SSH tunneling:
173
+
174
+ ```sh
175
+ # Deploy with MCP bound to all interfaces (0.0.0.0) — restrict via Tailscale ACLs/firewall
176
+ ./scripts/deploy-console.sh beta --mcp-bind 0.0.0.0
177
+ ```
178
+
179
+ Or set the environment variable before deploying:
180
+
181
+ ```sh
182
+ CATENCE_MCP_BIND=0.0.0.0 ./scripts/deploy-console.sh beta
183
+ ```
184
+
185
+ After deployment, the MCP server is available at `http://<tailscale-ip>:8787/mcp` from any device on your tailnet.
186
+
187
+ **Security note**: The MCP server has **no authentication** — it trusts the caller's `athleteId` parameter. When exposing on `0.0.0.0`, you **must** restrict access at the network layer (Tailscale ACLs, firewall rules, etc.). The Console UI remains on loopback (`127.0.0.1:8000`) and should still be fronted by Cloudflare Tunnel or a reverse proxy.
188
+
189
+ **Examples: Common MCP Clients**
190
+
191
+ ```sh
192
+ # Via SSH tunnel (default loopback deployment)
193
+ # Opencode
194
+ opencode mcp add catence -- http://127.0.0.1:8787/mcp
195
+
196
+ # Codex
197
+ codex mcp add catence -- http://127.0.0.1:8787/mcp
198
+
199
+ # Claude Desktop
200
+ claude mcp add --transport http catence -- http://127.0.0.1:8787/mcp
201
+ ```
202
+
203
+ ```sh
204
+ # Via Tailscale (when deployed with --mcp-bind 0.0.0.0)
205
+ # Replace <tailscale-ip> with your server's Tailscale IP (from `tailscale ip`)
206
+ # Opencode
207
+ opencode mcp add catence -- http://<tailscale-ip>:8787/mcp
208
+
209
+ # Codex
210
+ codex mcp add catence -- http://<tailscale-ip>:8787/mcp
211
+
212
+ # Claude Desktop
213
+ claude mcp add --transport http catence -- http://<tailscale-ip>:8787/mcp
214
+ ```
215
+
216
+ ### Why Not Expose Port 8787 Directly?
217
+
218
+ - The MCP server has **no authentication** — it trusts the caller's `athleteId` parameter.
219
+ - Exposing it would allow any network caller to read all athlete data in the catalog.
220
+ - The Console proxies dashboard requests through its **authenticated** same-origin route.
221
+ - SSH port forwarding keeps the trust boundary at your SSH credentials.
222
+
223
+ ---
224
+
225
+ ## Quick Reference: `docker compose run` vs `docker compose exec`
226
+
227
+ | Command Type | Use For | Example |
228
+ |--------------|---------|---------|
229
+ | `run --rm --entrypoint catence-data console` | **One-off setup/secrets** — needs fresh stdin for `--value-stdin`, no running container required | `secret set`, `setup`, `auth strava --code` |
230
+ | `exec console catence-data` | **Recurring ops** — uses the running container's environment (secrets already in volume) | `sync`, `backfill`, `status`, `build-retrieval-index` |
231
+
232
+ The `sync.sh` helper uses `exec` for this reason.
@@ -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.