catence 0.2.0-beta.8 → 0.2.0-beta.9

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 CHANGED
@@ -215,7 +215,9 @@ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.e
215
215
 
216
216
  ### Per-model reasoning effort
217
217
 
218
- Models that support reasoning effort (e.g., DeepSeek V4 Flash on Opencode Zen) can set it per deployment in `config.json`:
218
+ Models that support reasoning effort can set a fixed level per model in `config.json`, or expose a per-chat **Thinking effort** dropdown with custom levels via `variants`.
219
+
220
+ **Fixed level** (all requests for the model use it):
219
221
 
220
222
  ```json
221
223
  {
@@ -239,7 +241,19 @@ Models that support reasoning effort (e.g., DeepSeek V4 Flash on Opencode Zen) c
239
241
  }
240
242
  ```
241
243
 
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.
244
+ **Custom variants** (per-chat dropdown): `variants` maps each dropdown label to the value sent to the provider. This is how non-OpenAI models (for example DeepSeek V4 Flash, which uses its own Default/High/Max scheme rather than the OpenAI-standard list) expose their native levels:
245
+
246
+ ```json
247
+ "deepseek-v4-flash-free": {
248
+ "label": "DeepSeek V4 Flash Free",
249
+ "model": "openai/deepseek-v4-flash-free",
250
+ "variants": { "High": "high", "Max": "max" }
251
+ }
252
+ ```
253
+
254
+ With `variants`, the chat **Thinking effort** dropdown shows `Provider default` plus each label; selecting one sends that exact value (e.g. `reasoning_effort: "high"`). Without `variants`, the dropdown falls back to the OpenAI-standard set (`minimal`, `low`, `medium`, `high`, `xhigh`).
255
+
256
+ A fixed `reasoningEffort` overrides any profile-level `defaultReasoningEffort`; the per-chat dropdown selection (a variant value) overrides both. If nothing is set, the provider default is used.
243
257
 
244
258
  For source development:
245
259
 
@@ -49,7 +49,11 @@
49
49
  "label": "OpenCode Zen",
50
50
  "defaultModel": "deepseek-v4-flash-free",
51
51
  "models": {
52
- "deepseek-v4-flash-free": { "label": "DeepSeek V4 Flash Free", "model": "openai/deepseek-v4-flash-free" },
52
+ "deepseek-v4-flash-free": {
53
+ "label": "DeepSeek V4 Flash Free",
54
+ "model": "openai/deepseek-v4-flash-free",
55
+ "variants": { "High": "high", "Max": "max" }
56
+ },
53
57
  "nemotron-3-ultra-free": { "label": "Nemotron 3 Ultra Free", "model": "openai/nemotron-3-ultra-free" },
54
58
  "big-pickle": { "label": "Big Pickle", "model": "openai/big-pickle" }
55
59
  },
@@ -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.8";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.9";
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.8';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.9';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -0,0 +1,16 @@
1
+ import type { CatencePaths } from '../contracts/runtime.js';
2
+ export type SyncLogger = {
3
+ info: (message: string, context?: Record<string, unknown>) => void;
4
+ warn: (message: string, context?: Record<string, unknown>) => void;
5
+ error: (message: string, context?: Record<string, unknown>) => void;
6
+ debug: (message: string, context?: Record<string, unknown>) => void;
7
+ };
8
+ /**
9
+ * Resolve the sync log file path.
10
+ *
11
+ * `CATENCE_LOG` may be an absolute path or a path relative to the catalog home.
12
+ * When unset (the default), logs go to stderr only so the console stays clean
13
+ * and stdout remains machine-readable for callers like sync.sh.
14
+ */
15
+ export declare function resolveLogFile(paths: CatencePaths, environment?: NodeJS.ProcessEnv): string | null;
16
+ export declare function createSyncLogger(paths: CatencePaths, environment?: NodeJS.ProcessEnv): SyncLogger;
@@ -0,0 +1,68 @@
1
+ import { appendFile, mkdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const LEVEL_WEIGHT = { debug: 10, info: 20, warn: 30, error: 40 };
4
+ function configuredLevel(environment = process.env) {
5
+ const raw = (environment.CATENCE_LOG_LEVEL ?? 'info').toLowerCase();
6
+ if (raw === 'debug')
7
+ return LEVEL_WEIGHT.debug;
8
+ if (raw === 'warn')
9
+ return LEVEL_WEIGHT.warn;
10
+ if (raw === 'error')
11
+ return LEVEL_WEIGHT.error;
12
+ return LEVEL_WEIGHT.info;
13
+ }
14
+ /**
15
+ * Resolve the sync log file path.
16
+ *
17
+ * `CATENCE_LOG` may be an absolute path or a path relative to the catalog home.
18
+ * When unset (the default), logs go to stderr only so the console stays clean
19
+ * and stdout remains machine-readable for callers like sync.sh.
20
+ */
21
+ export function resolveLogFile(paths, environment = process.env) {
22
+ const configured = environment.CATENCE_LOG;
23
+ if (!configured)
24
+ return null;
25
+ return path.isAbsolute(configured) ? configured : path.join(paths.root, configured);
26
+ }
27
+ export function createSyncLogger(paths, environment = process.env) {
28
+ const threshold = configuredLevel(environment);
29
+ const logFile = resolveLogFile(paths, environment);
30
+ const write = (level, message, context) => {
31
+ if (LEVEL_WEIGHT[level] < threshold)
32
+ return;
33
+ const line = formatLogLine(level, message, context);
34
+ process.stderr.write(`${line}\n`);
35
+ if (logFile) {
36
+ // Fire-and-forget: never block a sync run on log persistence.
37
+ appendLogLine(logFile, `${line}\n`).catch(() => { });
38
+ }
39
+ };
40
+ return {
41
+ info: (message, context) => write('info', message, context),
42
+ warn: (message, context) => write('warn', message, context),
43
+ error: (message, context) => write('error', message, context),
44
+ debug: (message, context) => write('debug', message, context),
45
+ };
46
+ }
47
+ function formatLogLine(level, message, context) {
48
+ const timestamp = new Date().toISOString();
49
+ const tag = level.toUpperCase().padEnd(5);
50
+ let line = `[${timestamp}] [${tag}] ${message}`;
51
+ if (context && Object.keys(context).length > 0) {
52
+ line += ` ${serializeContext(context)}`;
53
+ }
54
+ return line;
55
+ }
56
+ function serializeContext(context) {
57
+ try {
58
+ return JSON.stringify(context);
59
+ }
60
+ catch {
61
+ return '';
62
+ }
63
+ }
64
+ let appendLogLine = async (file, line) => {
65
+ await mkdir(path.dirname(file), { recursive: true });
66
+ await appendFile(file, line, 'utf8');
67
+ };
68
+ //# sourceMappingURL=logging.js.map
@@ -8,6 +8,7 @@ import { promisify } from 'node:util';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { buildRetrievalIndex } from '../../core/retrieval/index.js';
10
10
  import { defaultFromDate, ensurePaths, requireIntervalsConfig } from '../../core/runtime/configuration.js';
11
+ import { createSyncLogger } from '../../core/logging.js';
11
12
  import { athleteProviderEnvironment } from '../../core/runtime/secrets.js';
12
13
  import { importJsonl } from '../ingestion/importer.js';
13
14
  import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
@@ -19,7 +20,7 @@ const execFileAsync = promisify(execFile);
19
20
  function packageRoot() {
20
21
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
21
22
  }
22
- async function syncProvider(database, paths, provider, options) {
23
+ async function syncProvider(database, paths, provider, options, log) {
23
24
  const explicitWindow = options.explicitFrom ? { fromDate: options.explicitFrom, toDate: options.toDate, source: 'explicit' } : null;
24
25
  const uncoveredExplicitWindow = async (cursorName) => {
25
26
  if (!explicitWindow)
@@ -33,6 +34,7 @@ async function syncProvider(database, paths, provider, options) {
33
34
  const activityWindow = explicitWindow ? await uncoveredExplicitWindow('activities') : await database.resolveIncrementalWindow(provider, 'activities', options.toDate, defaultFromDate(), 14);
34
35
  const hasProviderWork = provider === 'intervals' ? activityWindow !== null : dailyWindow !== null || activityWindow !== null;
35
36
  if (!hasProviderWork) {
37
+ log.debug('No uncovered work; skipping provider', { provider, daily: dailyWindow ?? null, activities: activityWindow ?? null });
36
38
  return {
37
39
  runId: null, provider, fromDate: null,
38
40
  dailyFromDate: dailyWindow?.fromDate ?? null, dailyToDate: dailyWindow?.toDate ?? null,
@@ -43,13 +45,19 @@ async function syncProvider(database, paths, provider, options) {
43
45
  }
44
46
  const fromDate = [dailyWindow?.fromDate, activityWindow?.fromDate].filter((date) => Boolean(date)).sort()[0];
45
47
  const runId = await database.beginRun(provider, fromDate);
48
+ log.info('Starting sync run', {
49
+ runId, provider, fromDate, toDate: options.toDate,
50
+ dailyFrom: dailyWindow?.fromDate ?? null, dailyTo: dailyWindow?.toDate ?? null,
51
+ activityFrom: activityWindow?.fromDate ?? null, activityTo: activityWindow?.toDate ?? null,
52
+ backfill: options.backfill, refreshActivities: options.refreshActivities,
53
+ });
46
54
  let extractionStarted = false;
47
55
  try {
48
56
  if (provider === 'intervals') {
49
57
  const environment = await athleteProviderEnvironment(paths);
50
58
  const config = requireIntervalsConfig(environment);
51
59
  extractionStarted = true;
52
- await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
60
+ await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
53
61
  }
54
62
  else {
55
63
  const output = path.join(paths.staging, 'garmin', `${runId}.jsonl`);
@@ -77,14 +85,18 @@ async function syncProvider(database, paths, provider, options) {
77
85
  });
78
86
  if (!existsSync(output))
79
87
  throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
80
- await importJsonl(database, runId, output);
88
+ await importJsonl(database, runId, output, log);
81
89
  }
82
90
  }
83
91
  catch (error) {
84
- await database.addError(runId, provider, 'sync', null, error instanceof Error ? error.message : String(error), true);
92
+ const message = error instanceof Error ? error.message : String(error);
93
+ log.error('Sync run failed', { runId, provider, error: message, stack: error instanceof Error ? error.stack : undefined });
94
+ await database.addError(runId, provider, 'sync', null, message, true);
85
95
  }
86
96
  finally {
87
97
  await database.finishRun(runId);
98
+ const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
99
+ log.info('Sync run finished', { runId, provider, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
88
100
  }
89
101
  if (options.advanceCursor && !options.explicitFrom && extractionStarted) {
90
102
  await database.advanceIncrementalCursor(provider, 'daily', runId, options.toDate, 3);
@@ -109,12 +121,13 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
109
121
  if (provider === 'strava')
110
122
  return { provider, result: await syncStravaGear(paths) };
111
123
  const database = await CatenceDatabase.open(paths);
124
+ const log = createSyncLogger(paths);
112
125
  try {
113
126
  const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
114
127
  const toDate = new Date().toISOString().slice(0, 10);
115
128
  const runs = [];
116
129
  for (const source of providers)
117
- runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }));
130
+ runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
118
131
  return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs };
119
132
  }
120
133
  finally {
@@ -130,7 +143,8 @@ export async function retryDataSync(paths, previousRunId) {
130
143
  throw new Error(`No sync run found for ${previousRunId}.`);
131
144
  if (previous.provider === 'strava')
132
145
  throw new Error('Retry Strava gear sync with `catence-data sync --provider strava`.');
133
- const run = await syncProvider(database, paths, previous.provider, { explicitFrom: previous.from_date, toDate: new Date().toISOString().slice(0, 10), advanceCursor: false, refreshActivities: false, backfill: false });
146
+ const log = createSyncLogger(paths);
147
+ const run = await syncProvider(database, paths, previous.provider, { explicitFrom: previous.from_date, toDate: new Date().toISOString().slice(0, 10), advanceCursor: false, refreshActivities: false, backfill: false }, log);
134
148
  return { retriedRun: previousRunId, runId: run.runId, run };
135
149
  }
136
150
  finally {
@@ -1,7 +1,8 @@
1
1
  import type { CatenceDatabase } from '../storage/database.js';
2
2
  import { type StagingRecord } from '../../contracts/staging.js';
3
- export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord): Promise<void>;
4
- export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
3
+ import type { SyncLogger } from '../../core/logging.js';
4
+ export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord, log?: SyncLogger): Promise<void>;
5
+ export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string, log?: SyncLogger): Promise<number>;
5
6
  export declare function importSingleStagingFile(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
6
7
  export declare function queueWorkItem(database: CatenceDatabase, runId: string, provider: 'intervals' | 'garmin' | 'strava', endpoint: string, remoteId: string | null, scope: unknown): Promise<string>;
7
8
  export declare function completeWorkItem(database: CatenceDatabase, itemId: string): Promise<void>;
@@ -7,7 +7,7 @@ import { importSourceEntity, refreshActivityQuality } from '../normalization/nor
7
7
  import { normalizeStravaEntity } from '../normalization/segments/strava-normalizer.js';
8
8
  import { reconcileActivityLink } from '../normalization/activities/linking.js';
9
9
  import { json } from '../storage/sql.js';
10
- export async function importRecord(database, runId, record) {
10
+ export async function importRecord(database, runId, record, log) {
11
11
  if (record.kind === 'run_manifest') {
12
12
  if (record.runId !== runId)
13
13
  throw new Error(`Staging manifest run ${record.runId} does not match importer run ${runId}.`);
@@ -54,8 +54,9 @@ export async function importRecord(database, runId, record) {
54
54
  return;
55
55
  }
56
56
  await database.addError(runId, record.provider, record.endpoint, record.remoteId, record.message, record.retryable);
57
+ log?.warn('Staging record reported an extraction error', { runId, provider: record.provider, endpoint: record.endpoint, remoteId: record.remoteId, error: record.message });
57
58
  }
58
- export async function importJsonl(database, runId, filePath) {
59
+ export async function importJsonl(database, runId, filePath, log) {
59
60
  let count = 0;
60
61
  const file = createReadStream(filePath, { encoding: 'utf8' });
61
62
  const lines = createInterface({ input: file, crlfDelay: Infinity });
@@ -63,7 +64,7 @@ export async function importJsonl(database, runId, filePath) {
63
64
  if (!line.trim())
64
65
  continue;
65
66
  const record = stagingRecordSchema.parse(JSON.parse(line));
66
- await importRecord(database, runId, record);
67
+ await importRecord(database, runId, record, log);
67
68
  count += 1;
68
69
  }
69
70
  return count;
@@ -1,12 +1,14 @@
1
1
  import type { CatenceDatabase } from '../../storage/database.js';
2
2
  import type { CatencePaths } from '../../../contracts/runtime.js';
3
+ import type { SyncLogger } from '../../../core/logging.js';
3
4
  export declare class IntervalsExtractor {
4
5
  private readonly database;
5
6
  private readonly paths;
6
7
  private readonly apiKey;
7
8
  private readonly athleteId;
9
+ private readonly log;
8
10
  private readonly client;
9
- constructor(database: CatenceDatabase, paths: CatencePaths, apiKey: string, athleteId: string);
11
+ constructor(database: CatenceDatabase, paths: CatencePaths, apiKey: string, athleteId: string, log: SyncLogger);
10
12
  sync(runId: string, activityWindow: {
11
13
  fromDate: string;
12
14
  toDate: string;
@@ -42,12 +42,14 @@ export class IntervalsExtractor {
42
42
  paths;
43
43
  apiKey;
44
44
  athleteId;
45
+ log;
45
46
  client;
46
- constructor(database, paths, apiKey, athleteId) {
47
+ constructor(database, paths, apiKey, athleteId, log) {
47
48
  this.database = database;
48
49
  this.paths = paths;
49
50
  this.apiKey = apiKey;
50
51
  this.athleteId = athleteId;
52
+ this.log = log;
51
53
  // The typed client is used for provider-compatible authentication and the
52
54
  // primary identity request. Manifest-only calls below intentionally use
53
55
  // the same read-only credentials to cover endpoint families not wrapped by it.
@@ -189,6 +191,7 @@ export class IntervalsExtractor {
189
191
  catch (error) {
190
192
  const message = error instanceof Error ? error.message : String(error);
191
193
  await failWorkItem(this.database, workId, message);
194
+ this.log.warn('Endpoint capture failed', { runId, provider: 'intervals', endpoint, remoteId, error: message });
192
195
  await this.database.addError(runId, 'intervals', endpoint, remoteId, message, true);
193
196
  return undefined;
194
197
  }
@@ -8,6 +8,7 @@ import { ensurePaths, loadCatenceConfig } from '../../../../core/runtime/configu
8
8
  import { athleteProviderEnvironment } from '../../../../core/runtime/secrets.js';
9
9
  import { CatenceDatabase, openReadOnlyRepository } from '../../../storage/database.js';
10
10
  import { importJsonl } from '../../importer.js';
11
+ import { createSyncLogger } from '../../../../core/logging.js';
11
12
  import { withDataWriteLock } from '../../../storage/write-lock.js';
12
13
  const execFileAsync = promisify(execFile);
13
14
  export class StravaRateLimitError extends Error {
@@ -108,6 +109,7 @@ async function cachedState(database, resourceType, remoteId) {
108
109
  async function runReadOperation(paths, operation, extraArgs, resource, refresh = false) {
109
110
  return withDataWriteLock(paths, async () => {
110
111
  await ensurePaths(paths);
112
+ const log = createSyncLogger(paths);
111
113
  const database = await CatenceDatabase.open(paths);
112
114
  try {
113
115
  if (!refresh) {
@@ -117,6 +119,7 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
117
119
  }
118
120
  await checkBudget(database, paths);
119
121
  const runId = await database.beginRun('strava', new Date().toISOString().slice(0, 10));
122
+ log.info('Starting Strava read operation', { runId, operation, resource: resource.id });
120
123
  const directory = path.join(paths.staging, 'strava');
121
124
  const stagingPath = path.join(directory, `${runId}.jsonl`);
122
125
  const resultPath = path.join(directory, `${runId}.result.json`);
@@ -124,17 +127,20 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
124
127
  try {
125
128
  result = await invokeWorker(paths, ['--mode', operation, '--run-id', runId, '--output', stagingPath, ...extraArgs], resultPath);
126
129
  if (existsSync(stagingPath))
127
- await importJsonl(database, runId, stagingPath);
130
+ await importJsonl(database, runId, stagingPath, log);
128
131
  await storeRateState(database, result);
129
132
  await writeState(database, resource.type, resource.id, result);
130
133
  }
131
134
  catch (error) {
132
135
  const message = error instanceof Error ? error.message : String(error);
136
+ log.error('Strava read operation failed', { runId, operation, resource: resource.id, error: message, stack: error instanceof Error ? error.stack : undefined });
133
137
  await database.addError(runId, 'strava', operation, resource.id, message, true);
134
138
  throw error;
135
139
  }
136
140
  finally {
137
141
  await database.finishRun(runId);
142
+ const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
143
+ log.info('Strava read operation finished', { runId, operation, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
138
144
  }
139
145
  if (result.status === 'error')
140
146
  throw new StravaEnrichmentError(result.message ?? 'Strava did not complete the requested enrichment.');
@@ -94,9 +94,11 @@ export class CatenceDatabase {
94
94
  const rawObjects = await this.rows('SELECT count(*)::INTEGER AS count FROM raw_objects');
95
95
  const activities = await this.rows('SELECT count(*)::INTEGER AS count FROM activities');
96
96
  const streams = await this.rows('SELECT count(*)::INTEGER AS count FROM stream_manifest');
97
+ const errorDetails = await this.rows('SELECT run_id, provider, endpoint, remote_id, message, cast(created_at AS VARCHAR) AS created_at FROM normalization_errors WHERE resolved_at IS NULL ORDER BY created_at DESC LIMIT 100');
97
98
  const cursors = await this.rows('SELECT provider, cursor_name, cast(covered_through_date AS VARCHAR) AS covered_through_date, cast(latest_source_date AS VARCHAR) AS latest_source_date, lookback_days, last_successful_run_id, cast(last_completed_at AS VARCHAR) AS last_completed_at, status FROM sync_cursors ORDER BY provider, cursor_name');
98
99
  return {
99
100
  runs, unresolvedErrors: errors[0]?.count ?? 0, rawObjects: rawObjects[0]?.count ?? 0, activities: activities[0]?.count ?? 0, streams: streams[0]?.count ?? 0,
101
+ errors: errorDetails,
100
102
  cursors: cursors.map((cursor) => ({ ...cursor, next_from_date: subtractDays(cursor.covered_through_date, Number(cursor.lookback_days)) })),
101
103
  };
102
104
  }
@@ -373,6 +373,49 @@ settings then offer a per-deployment choice.
373
373
  }
374
374
  ```
375
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
+ You can also pin a fixed level with `reasoningEffort` on the model, or a
415
+ profile-wide default with `defaultReasoningEffort` (OpenAI-standard values
416
+ only). Precedence: per-chat dropdown selection > model `reasoningEffort` >
417
+ profile `defaultReasoningEffort` > provider default.
418
+
376
419
  ## Verifying a profile
377
420
 
378
421
  `catence-console doctor` reports, for each profile, which environment variables
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.8",
3
+ "version": "0.2.0-beta.9",
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": {