triss-coworker 0.24.2 → 0.25.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.25.0] — 2026-07-27
11
+
12
+ ### Added
13
+
14
+ - `triss ask` and `triss review` now accept `--provider glm` (also available
15
+ as the `provider` field on `triss_ask` / `triss_review` MCP tools). They reuse
16
+ `ZHIPU_API_KEY` for direct OpenAI-compatible chat completions, while the
17
+ existing `worker` provider remains the default and `deepseek` is an alias.
18
+ `--model pro` maps to `glm-5.2`, and `--model flash` — Triss's cheap
19
+ bulk-read tier — maps to `glm-4.7` on the subscription endpoint and
20
+ `glm-4.5-air` on pay-as-you-go. Explicit `zai-coding-plan/<model>` /
21
+ `zai/<model>` prefixes select the endpoint, and may carry a preset
22
+ (`zai/flash`). Usage records retain that endpoint identity: subscription
23
+ calls remain known `$0` because the plan meters by quota, while PAYG calls
24
+ are priced from Z.AI's published rates.
25
+ - `triss status` and the `triss_status` MCP tool print a GLM routing block:
26
+ whether `ZHIPU_API_KEY` is set, the endpoint a `--provider glm` call resolves
27
+ to, what selected it, and that endpoint's preset models. Worker credential
28
+ rows are now labelled as such, so a GLM-only setup no longer reads as
29
+ unconfigured just because it has no worker key.
30
+ - Pay-as-you-go GLM models now ship with Z.AI's published list prices, so
31
+ `triss usage` reports a real cost for them instead of `unknown`.
32
+ Provider selection is intentionally scoped to `ask` and `review`;
33
+ `chat`, `fetch`, `write`, and `commit-msg` keep their existing worker route,
34
+ while `triss coder` remains the separate agentic path.
35
+
36
+ ### Changed
37
+
38
+ - Usage JSONL records keep `cost_usd` numeric for compatibility with existing
39
+ dashboards and add `cost_usd_known: false` when no model price is configured.
40
+ `triss usage` excludes those zeros from known totals and reports them as
41
+ unknown. This includes unpriced `opencode/*` OpenCode Zen models; configure
42
+ `TRISS_PRICE_<MODEL_ID>` to account for one. Existing log entries are not
43
+ rewritten: older bare GLM model ids and their previously recorded numeric
44
+ costs remain unchanged.
45
+
46
+ ### Fixed
47
+
48
+ - A GLM call that nothing pinned to an endpoint is no longer a one-shot guess.
49
+ A Z.AI key carries no marker of which plan it belongs to, so a pay-as-you-go
50
+ key used to fail outright against the `zai-coding-plan` default. Such a call
51
+ is now retried once on the other endpoint; the working one is reported on
52
+ stderr and reused for the rest of the process, and re-probed if the key
53
+ changes. The cache stores only the working endpoint, never the API key or a
54
+ fingerprint derived from it. Endpoints pinned by an explicit prefix or
55
+ `TRISS_CODER_MODEL` are never second-guessed, and a key that works on neither
56
+ endpoint still surfaces the original rejection.
57
+ - A preset behind a provider prefix is resolved instead of being sent
58
+ verbatim: `zai/flash` previously reached the provider as the literal model
59
+ id `flash`.
60
+ - Long-lived MCP processes now refresh file-backed `TRISS_CODER_MODEL` and
61
+ `ZHIPU_API_KEY` values between calls, including edits and removals, while
62
+ preserving the precedence of values supplied by the parent environment.
63
+ - Every model routed through the `zai-coding-plan/` subscription endpoint is
64
+ treated as known plan-metered usage instead of requiring a hardcoded model
65
+ allowlist. Explicit `TRISS_PRICE_<MODEL>` overrides still take precedence.
66
+ - A GLM `HTTP 429` now carries the resolved-endpoint hint instead of surfacing
67
+ Z.AI's bare "Insufficient balance or no resource package" text. That reply is
68
+ what a subscription key gets on the pay-as-you-go endpoint, so the message now
69
+ names both the balance and the wrong-plan cause.
70
+
10
71
  ## [0.24.2] — 2026-07-15
11
72
 
12
73
  ### Changed
package/README.md CHANGED
@@ -288,6 +288,10 @@ triss ask --paths src/auth.ts src/db.ts \
288
288
  triss ask --paths "src/**/*.ts" \
289
289
  --question "Find SQL injection risks" \
290
290
  --model pro --max-tokens 16384
291
+
292
+ triss ask --paths "src/**/*.ts" \
293
+ --question "Find SQL injection risks" \
294
+ --provider glm --model pro
291
295
  ```
292
296
 
293
297
  Typical output is a focused answer with cited file paths, not a raw file dump.
@@ -335,7 +339,7 @@ triss usage --json # raw log records
335
339
  triss usage --reset # clear the log
336
340
  ```
337
341
 
338
- Every worker call (CLI **and** MCP) appends one record to
342
+ Every model call (CLI **and** MCP) appends one record to
339
343
  `~/.cache/triss/usage.jsonl` with model, tokens, cached tokens, computed
340
344
  USD cost, working directory, a label like `triss/ask`, and a per-invocation
341
345
  `call_id` (UUIDv4). An optional `parent_call_id` lets a host group several
@@ -343,9 +347,17 @@ Triss calls under a single outer session — set `TRISS_PARENT_CALL_ID` in
343
347
  the environment that launches Triss (e.g. in your MCP server's `env`
344
348
  block) and every record from that process carries it. External dashboards
345
349
  (e.g. tokentelemetry) can group by either field. List-price defaults for
346
- DeepSeek are baked in; override per-model with
347
- `TRISS_PRICE_<MODEL_ID>=miss,hit,out` (USD per token). Disable tracking
348
- entirely with `TRISS_USAGE_LOG=0`.
350
+ DeepSeek are baked in. GLM records retain `zai-coding-plan/` or `zai/` so
351
+ subscription usage stays known `$0` while unpriced PAYG usage is shown as
352
+ `unknown`, never as free. Any other unpriced model, including `opencode/*`
353
+ OpenCode Zen, is also `unknown` unless configured with a matching
354
+ `TRISS_PRICE_<MODEL_ID>` override. The JSONL keeps `cost_usd` numeric for
355
+ dashboard compatibility and marks unpriced records with `cost_usd_known: false`.
356
+ Existing records are not migrated, so older bare GLM model ids remain as
357
+ originally logged. Override per-model with
358
+ `TRISS_PRICE_<MODEL_ID>=miss,hit,out` (USD per token), for example
359
+ `TRISS_PRICE_ZAI_GLM_5_2=...`. Disable tracking entirely with
360
+ `TRISS_USAGE_LOG=0`.
349
361
 
350
362
  ### Streaming output
351
363
 
@@ -391,6 +403,7 @@ triss review # current branch vs auto-detected base
391
403
  triss review 123 # GitHub PR #123 (requires `gh` CLI)
392
404
  triss review --base develop # explicit base
393
405
  triss review --skip-issue # don't try ticket lookup
406
+ triss review --provider glm # same review flow, one-shot GLM inference
394
407
  ```
395
408
 
396
409
  Defaults to the `pro` preset because review needs reasoning. Output is
@@ -582,6 +595,34 @@ requires an OpenAI-compatible chat-completions endpoint.
582
595
 
583
596
  You can also pass any model id directly: `--model deepseek-v4-pro`.
584
597
 
598
+ `ask` and `review` can route the same one-shot request to GLM without invoking
599
+ the agentic `coder` runtime:
600
+
601
+ ```bash
602
+ triss ask --paths ... --question "..." --provider glm --model flash
603
+ triss review --provider glm --model pro
604
+ triss review --provider glm --model zai/glm-5.2 # pay-as-you-go endpoint
605
+ ```
606
+
607
+ For GLM, `pro` maps to `glm-5.2` on both endpoints, while `flash` — the cheap
608
+ bulk-read tier — maps to `glm-4.7` on the subscription endpoint and to
609
+ `glm-4.5-air` ($0.20/$1.10 per 1M) on pay-as-you-go. The subscription endpoint
610
+ serves a `glm-4.5-air` request as `glm-4.7`, so the preset names what actually
611
+ runs there.
612
+
613
+ `ZHIPU_API_KEY` is reused. Prefix an explicit model with `zai-coding-plan/`
614
+ or `zai/` to select the subscription or pay-as-you-go endpoint; otherwise
615
+ Triss inherits the prefix from `TRISS_CODER_MODEL` and falls back to the
616
+ coding-plan endpoint. A Z.AI key does not say which plan it belongs to, so
617
+ when nothing pinned the endpoint and the call comes back `401`/`403`/`429`,
618
+ Triss retries once on the other endpoint, prints which one worked, and reuses
619
+ it for the rest of the process. Pin it (`TRISS_CODER_MODEL=zai/glm-5.2`, or run
620
+ `triss coder init`) to skip that probe. `triss status` shows the resolved
621
+ endpoint, where it came from, and the presets it selects.
622
+
623
+ `triss coder` stays separate because it is an agent runtime with tools,
624
+ sessions, and worktree isolation.
625
+
585
626
  ### Provider recipes
586
627
 
587
628
  #### DeepSeek (default, recommended)
package/bin/triss.js CHANGED
@@ -66,16 +66,19 @@ program
66
66
 
67
67
  program
68
68
  .command('ask')
69
- .description('Delegate bulk reading (files, URLs, and/or piped stdin) to DeepSeek; returns a structured summary')
69
+ .description('Delegate bulk reading to the configured worker or GLM; returns a structured summary')
70
70
  .option('-p, --paths <paths...>', 'files or globs to read')
71
71
  .option('-u, --urls <urls...>', 'http(s) URLs to fetch and convert to markdown')
72
72
  .option('--stdin', 'read piped stdin as an additional source (for `cmd | triss ask --stdin ...`)')
73
73
  .requiredOption('-q, --question <text>', 'question to answer about the corpus')
74
+ .option('--provider <name>', 'inference provider: worker (default), deepseek (alias), or glm')
74
75
  .option('-m, --model <name>', 'model preset (flash | pro) or full model id')
75
76
  .option('--max-tokens <n>', 'token budget for reasoning + answer', (v) => parseInt(v, 10), 8192)
76
77
  .option('--system <text>', 'override the system prompt')
77
78
  .option('--no-stream', "disable streaming output (default streams when stdout is a TTY)")
78
- .action(wrap(runAsk));
79
+ // Commander also supplies its Command instance to action handlers. Adapt
80
+ // at the CLI boundary so runAsk receives only its documented options.
81
+ .action((opts) => wrap(runAsk)(opts));
79
82
 
80
83
  program
81
84
  .command('write')
@@ -128,10 +131,11 @@ program
128
131
 
129
132
  program
130
133
  .command('review [pr]')
131
- .description('Code review via DeepSeek. No arg: current branch vs default base. With <pr>: GitHub PR via gh.')
134
+ .description('Code review via the configured worker or GLM. No arg: current branch vs default base. With <pr>: GitHub PR via gh.')
132
135
  .option('-b, --base <branch>', 'compare against this branch (default: auto-detect origin/HEAD or main/master/develop)')
133
136
  .option('--skip-issue', "don't try to look up a Jira/Linear ticket from the branch/PR title")
134
137
  .option('-q, --question <text>', 'override the default review question')
138
+ .option('--provider <name>', 'inference provider: worker (default), deepseek (alias), or glm')
135
139
  .option('-m, --model <name>', 'model preset (flash | pro) or full model id (default: pro)')
136
140
  .option('--max-tokens <n>', 'token budget for the review (default 8192)')
137
141
  .option('--no-stream', 'disable streaming output')
@@ -218,12 +218,21 @@ env:
218
218
 
219
219
  You added `.triss.env` to a project after Claude Code was already
220
220
  running there. As of v0.9.3, **no restart needed** — `.env` files are
221
- re-read on every `tools/list` request, so the next time the agent or
222
- `claude /mcp` lists tools, the new ones appear (or disappear).
221
+ re-read on every `tools/list` request, so newly added integration
222
+ credentials can expose their tools on the next listing. Replacing or removing
223
+ an integration credential that is already loaded still requires restarting
224
+ that MCP process.
223
225
 
224
226
  The only thing that requires a restart is the *initial* MCP-server
225
227
  registration — i.e. running `triss mcp install` for the first time.
226
- After that, credential changes are live.
228
+ After that, newly added credentials are discovered live.
229
+
230
+ Separately, GLM calls (`triss_ask` / `triss_review` with `provider: "glm"`)
231
+ re-read file-backed `TRISS_CODER_MODEL` and `ZHIPU_API_KEY` on each call.
232
+ Editing or removing either value in `.triss.env` takes effect on the next GLM
233
+ call without restarting the MCP server. Values supplied by the parent process
234
+ environment still take precedence. This is a GLM per-call guarantee; it does
235
+ not extend the tool-catalogue reload behaviour above to every integration.
227
236
 
228
237
  ## Environment reference
229
238
 
@@ -241,11 +250,11 @@ self-hosted endpoints).
241
250
  | `TRISS_WORKER_PRO_MODEL` | no | `deepseek-v4-pro` | Override the `pro` preset |
242
251
  | `TRISS_DEFAULT_MODEL` | no | `flash` | Which preset is used when no `--model`|
243
252
 
244
- ### Coder (GLM coding agent)
253
+ ### GLM and coder
245
254
 
246
255
  | Variable | Required | Default | Notes |
247
256
  | -------------------------------- | -------- | ------------------ | ------------------------------------------ |
248
- | `ZHIPU_API_KEY` | yes¹ | — | Z.AI API key for GLM models — <https://z.ai/manage-apikey/apikey-list> |
257
+ | `ZHIPU_API_KEY` | yes¹ | — | Z.AI API key for `ask`/`review --provider glm` and GLM coder models — <https://z.ai/manage-apikey/apikey-list> |
249
258
  | `OPENCODE_API_KEY` | no¹ | — | OpenCode Zen key (opencode engine only) — unlocks `opencode/*` models like `opencode/hy3-free` — <https://opencode.ai/docs/zen/> |
250
259
  | `TRISS_CODER_MODEL` | no | `zai-coding-plan/glm-5.2` | Resolved model, passed to opencode via `--model` (and written to `opencode.json` by `init`). Use `opencode/hy3-free` for OpenCode Zen |
251
260
  | `TRISS_CODER_SMALL_MODEL` | no | `zai-coding-plan/glm-5-turbo` | Small/fast model written to `opencode.json`|
@@ -265,6 +274,30 @@ file) when the existing `model` prefix doesn't match what the key
265
274
  verified against — that mismatch is what makes opencode retry a model
266
275
  call it can never complete.
267
276
 
277
+ `triss ask` and `triss review` can use the same key without spawning a coding
278
+ agent: pass `--provider glm`. `pro` maps to `glm-5.2` on both endpoints;
279
+ `flash` maps to `glm-4.7` on `zai-coding-plan` and `glm-4.5-air` on `zai`,
280
+ because the subscription endpoint answers a `glm-4.5-air` request with
281
+ `glm-4.7` and a preset should name the model that actually runs. A
282
+ provider-prefixed model selects the endpoint explicitly
283
+ (`zai-coding-plan/glm-5.2` for a subscription key, `zai/glm-5.2` for
284
+ pay-as-you-go), and a prefix may carry a preset too — `zai/flash` means "the
285
+ pay-as-you-go flash model". A bare model id inherits the prefix from
286
+ `TRISS_CODER_MODEL`, then falls back to `zai-coding-plan`.
287
+
288
+ **Endpoint auto-correction.** A Z.AI key carries no marker of which plan it
289
+ belongs to, so an unpinned `zai-coding-plan` route is a guess. When nothing
290
+ pinned the endpoint — no explicit prefix, no `TRISS_CODER_MODEL` — and the
291
+ call is rejected with `401`, `403`, or `429` (Z.AI answers a plan mismatch
292
+ with "Insufficient balance or no resource package"), Triss retries the request
293
+ once on the other endpoint. If that works, it warns on stderr and reuses that
294
+ endpoint for the rest of the process; the discovery is dropped when the key
295
+ changes. An explicitly prefixed or `TRISS_CODER_MODEL`-pinned endpoint is
296
+ never second-guessed. Pin one to skip the probe entirely — and note that a
297
+ pinned endpoint also selects that endpoint's presets. `triss status` and the
298
+ `triss_status` MCP tool print the resolved endpoint, its source, and the
299
+ preset models.
300
+
268
301
  ¹ **Credentials are provider-specific.** A run needs the one key its
269
302
  resolved model requires: `zai-coding-plan/*` and `zai/*` (GLM) need
270
303
  `ZHIPU_API_KEY`; `opencode/*` OpenCode Zen models (e.g. the free
@@ -337,7 +370,7 @@ are unaffected.
337
370
  | `TRISS_USAGE_LOG` | (on) | `0` disables the usage tracker (`~/.cache/triss/usage.jsonl`) |
338
371
  | `TRISS_USAGE_LOG_CWD` | (on) | `0` omits the absolute cwd from each record (then `--by-project` groups under `(unknown)`) |
339
372
  | `TRISS_USAGE_LOG_MAX_BYTES` | `10485760` | Rotate the active log to `usage.jsonl.old` once it crosses this size (10 MB default) |
340
- | `TRISS_PRICE_<MODEL_ID>` | list prices | `miss,hit,out` USD-per-token override per model (e.g. for promo or non-DeepSeek providers) |
373
+ | `TRISS_PRICE_<MODEL_ID>` | list prices | `miss,hit,out` USD-per-token override per model (e.g. `TRISS_PRICE_ZAI_GLM_5_2` for `zai/glm-5.2`); models without a price, including `opencode/*` Zen models, report `unknown`, not `$0` |
341
374
  | `TRISS_FETCH_MAX_BYTES` | `10485760` | Max body size for `triss fetch` (default 10 MB) |
342
375
  | `TRISS_RESTRICT_PATHS` | `1` in MCP, unset in CLI | `0` opts the MCP server out of the project-root file IO sandbox |
343
376
  | `TRISS_ALLOW_PRIVATE_NETWORKS` | (off) | `1` allows `triss fetch` / `triss ask --urls` to hit RFC1918, loopback, link-local, and cloud-metadata IPs. Off blocks SSRF; turn on only for self-hosted internal docs. **Known residual risk:** the guard checks DNS once before fetch; the underlying connection performs another lookup, leaving a narrow DNS-rebinding window. For high-trust environments use network-level egress filtering as the primary control. |
@@ -367,7 +400,18 @@ baked-in DeepSeek list prices (e.g. you point Triss at a different
367
400
  provider, or want the discounted rate), set
368
401
  `TRISS_PRICE_<MODEL>=<input_cache_miss>,<input_cache_hit>,<output>` in
369
402
  USD per token. Example: `TRISS_PRICE_DEEPSEEK_V4_PRO=4.35e-7,3.625e-9,8.7e-7`
370
- applies the 75% promotional pricing to the pro preset.
403
+ applies the 75% promotional pricing to the pro preset. PAYG GLM calls use the
404
+ canonical `zai/<model>` id and ship with Z.AI's published list prices for the
405
+ models both endpoints advertise (`glm-4.5`, `glm-4.5-air`, `glm-4.6`,
406
+ `glm-4.7`, `glm-5`, `glm-5-turbo`, `glm-5.1`, `glm-5.2`), so a PAYG cost is
407
+ reported, not guessed. Override any of them the same way — `zai/glm-5.2` maps
408
+ to `TRISS_PRICE_ZAI_GLM_5_2`. Subscription (`zai-coding-plan/*`) calls stay
409
+ `$0` because the plan meters by quota rather than tokens. Any model outside
410
+ that catalogue — a newly released GLM id, `opencode/*` OpenCode Zen models —
411
+ is explicitly unknown until you set its matching `TRISS_PRICE_<MODEL_ID>`
412
+ override. Usage JSONL keeps `cost_usd` numeric for
413
+ compatibility and marks these records with `cost_usd_known: false`; existing
414
+ historical records are not relabelled.
371
415
 
372
416
  **Claude Code integration step in the wizard** — split by mode:
373
417
 
package/docs/mcp.md CHANGED
@@ -187,22 +187,50 @@ or use the other project's credentials.
187
187
  `.env` files are re-read on every `tools/list` request, so adding a
188
188
  new credential to `.triss.env` mid-session means the next time the
189
189
  agent (or `claude /mcp`) lists tools, the new ones show up without
190
- restarting Claude Code.
190
+ restarting Claude Code. Replacing or removing an integration credential that
191
+ is already loaded still requires restarting that MCP process.
192
+
193
+ GLM calls have a narrower per-call refresh path: `triss_ask` and
194
+ `triss_review` with `provider: "glm"` re-read file-backed
195
+ `TRISS_CODER_MODEL` and `ZHIPU_API_KEY` before each call. Edits and removals
196
+ take effect without restarting the MCP server, except that values supplied by
197
+ the parent environment continue to win. This does not change the existing
198
+ `tools/list` refresh behaviour for other integrations.
191
199
 
192
200
  ## What tools are exposed
193
201
 
194
202
  The set of MCP tools is **filtered by which integrations are configured**.
195
203
  Run `triss status` to see readiness.
196
204
 
197
- Always exposed (require only `TRISS_WORKER_API_KEY`):
205
+ Core tools are always listed. Model calls default to the worker and require
206
+ `TRISS_WORKER_API_KEY`:
198
207
 
199
208
  - `triss_chat` — bare prompt to the worker model
200
- - `triss_ask` — read files/URLs and answer a question
209
+ - `triss_ask` — read files/URLs and answer a question; accepts an optional
210
+ `provider` field: `worker` (default), `deepseek` (an alias for `worker`),
211
+ or `glm`
201
212
  - `triss_fetch` — fetch URL(s) as markdown, optional summary
202
- - `triss_review` — code review on current branch or a GitHub PR
213
+ - `triss_review` — code review on current branch or a GitHub PR; accepts the
214
+ same optional `provider` field as `triss_ask`
203
215
  - `triss_commit_msg` — generate a Conventional Commits message from staged diff
204
216
  - `triss_write` — generate boilerplate from a spec; with `target` writes the file (path-sandboxed), without `target` returns the content
205
- - `triss_status` — current configuration and integration readiness
217
+ - `triss_status` — current configuration and integration readiness, including a
218
+ GLM block: whether `ZHIPU_API_KEY` is set, the endpoint a `provider: "glm"`
219
+ call resolves to, what selected it, and the preset models. A GLM-only server
220
+ has no worker key, so the worker rows say `(missing)` while GLM calls work —
221
+ the two are labelled separately for that reason.
222
+
223
+ For `triss_ask` and `triss_review`, `provider: "glm"` requires
224
+ `ZHIPU_API_KEY` rather than `TRISS_WORKER_API_KEY`. `pro` selects `glm-5.2` on
225
+ both endpoints; `flash` selects `glm-4.7` on the subscription endpoint and
226
+ `glm-4.5-air` on pay-as-you-go (the subscription endpoint serves an
227
+ `glm-4.5-air` request as `glm-4.7`). Use `zai-coding-plan/<model>` for the
228
+ subscription endpoint or `zai/<model>` for pay-as-you-go — a prefix may carry
229
+ a preset, so `zai/flash` is valid. When neither the request nor
230
+ `TRISS_CODER_MODEL` pinned an endpoint and the call is rejected with
231
+ `401`/`403`/`429`, the server retries once on the other endpoint and reuses
232
+ whichever one works for the rest of the process (re-probed if the key
233
+ changes). A pinned endpoint is never second-guessed.
206
234
 
207
235
  Exposed **only when ATLASSIAN_BASE_URL/EMAIL/API_TOKEN are all set**
208
236
  (both Jira and Confluence reuse the same Atlassian credentials):
@@ -368,9 +396,10 @@ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | triss mcp serve
368
396
  the `mcpServers` table in `~/.claude.json` is read at startup, so a
369
397
  fresh `triss mcp install` requires a session restart to take effect.
370
398
 
371
- **"Triss listed but tools/list is empty"** — `triss status` to check
372
- the API key. The MCP server connects regardless, but the model-using
373
- tools (`triss_chat`, `triss_ask`, …) need a working DeepSeek key.
399
+ **"Triss listed but a model call fails"** — `triss status` to check the
400
+ provider's API key. Core tools are always listed: their default `worker`
401
+ route needs `TRISS_WORKER_API_KEY`, while `triss_ask` and `triss_review` with
402
+ `provider: "glm"` need `ZHIPU_API_KEY`.
374
403
 
375
404
  **"Jira tools missing in this project"** — Triss couldn't find the
376
405
  ATLASSIAN_* triple. Either your global `~/.config/triss/.env` has them
@@ -412,6 +441,16 @@ Every MCP tool call gets its own `call_id` (UUIDv4) written to
412
441
  fields. Dashboards (e.g. tokentelemetry) can group records per
413
442
  invocation.
414
443
 
444
+ GLM records keep the endpoint the call actually used (`zai-coding-plan/` or
445
+ `zai/`) — including after an endpoint auto-correction, so a retried call is
446
+ billed to the endpoint that served it. Subscription calls retain the known
447
+ `$0` accounting (the plan meters by quota), and PAYG calls carry Z.AI's
448
+ published list prices for the catalogue models. Anything still unpriced —
449
+ a GLM id outside that catalogue, `opencode/*` OpenCode Zen — keeps numeric
450
+ `cost_usd: 0` for JSONL compatibility, adds `cost_usd_known: false`, and is
451
+ displayed by `triss usage` as `unknown`, not as free. Set the matching
452
+ `TRISS_PRICE_<MODEL_ID>` override to account for one.
453
+
415
454
  To attribute all calls from one MCP server process to a single outer
416
455
  session, set `TRISS_PARENT_CALL_ID` in the server's `env` block — every
417
456
  record from that process will carry the value as `parent_call_id`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triss-coworker",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
4
4
  "description": "Give your AI coding agent a cheap DeepSeek coworker. Delegate bulk reads, boilerplate, and doc updates to save 60-70% of your token budget.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/client.js CHANGED
@@ -1,21 +1,54 @@
1
1
  import OpenAI from 'openai';
2
- import { getConfig, requireApiKey } from './config.js';
2
+ import pc from 'picocolors';
3
+ import { getConfig, requireApiKey, requireGlmApiKey } from './config.js';
3
4
  import { logUsage } from './usage.js';
4
5
  import { currentCall } from './call-context.js';
6
+ import {
7
+ ZAI_CODING_PLAN_BASE_URL,
8
+ normalizeZaiBaseUrl,
9
+ siblingZaiBaseUrl,
10
+ zaiPrefixForBaseUrl,
11
+ } from './zai.js';
12
+
13
+ export function billingModelFor({ provider, baseUrl, model }) {
14
+ if (provider !== 'glm') return model || '(unknown)';
15
+
16
+ const prefix = zaiPrefixForBaseUrl(baseUrl);
17
+ if (!prefix || !model) return model || '(unknown)';
18
+
19
+ // Z.AI responses currently return a bare model id, but avoid duplicating a
20
+ // prefix if a compatible endpoint starts returning one later.
21
+ const modelId = String(model).replace(/^zai(?:-coding-plan)?\//, '');
22
+ return `${prefix}/${modelId}`;
23
+ }
24
+
25
+ export function glmRouteHint(baseUrl) {
26
+ const endpoint = normalizeZaiBaseUrl(baseUrl);
27
+ return (
28
+ `This request resolved to ${endpoint}. A bare GLM model id uses the resolved endpoint. ` +
29
+ 'Use zai/<model> for pay-as-you-go or zai-coding-plan/<model> for the subscription endpoint.'
30
+ );
31
+ }
5
32
 
6
33
  // Recreate the OpenAI client per call so a long-lived MCP server picks
7
34
  // up `triss config set TRISS_WORKER_API_KEY` changes mid-session. Constructing
8
35
  // the client is microseconds — no observable overhead next to a model RTT.
9
- export function getClient() {
36
+ export function getClient({ provider = 'worker', baseUrl } = {}) {
37
+ if (provider === 'glm') {
38
+ return new OpenAI({
39
+ apiKey: requireGlmApiKey(),
40
+ baseURL: baseUrl || ZAI_CODING_PLAN_BASE_URL,
41
+ });
42
+ }
10
43
  const cfg = requireApiKey(getConfig());
11
44
  return new OpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseUrl });
12
45
  }
13
46
 
14
- function recordUsage(resp, label) {
47
+ function recordUsage(resp, label, request = {}) {
15
48
  if (!resp?.usage) return;
16
49
  const ctx = currentCall();
17
50
  logUsage({
18
- model: resp.model || '(unknown)',
51
+ model: billingModelFor({ ...request, model: resp.model || request.model }),
19
52
  prompt_tokens: resp.usage.prompt_tokens,
20
53
  cached_tokens: resp.usage.prompt_tokens_details?.cached_tokens,
21
54
  completion_tokens: resp.usage.completion_tokens,
@@ -25,33 +58,119 @@ function recordUsage(resp, label) {
25
58
  });
26
59
  }
27
60
 
28
- export async function chat({ model, messages, maxTokens, temperature, label }) {
29
- const client = getClient();
61
+ // Statuses Z.AI returns when the key does not match the endpoint the request
62
+ // resolved to. 429 belongs here despite reading as a rate limit: a Coding Plan
63
+ // key sent to the PAYG endpoint answers "Insufficient balance or no resource
64
+ // package. Please recharge." — a billing message for what is really a routing
65
+ // mismatch, so the endpoint hint matters more there than anywhere else.
66
+ const GLM_ROUTE_STATUSES = new Set([401, 403, 429]);
67
+
68
+ export function providerRequestError(err, { provider, baseUrl, model }) {
69
+ const status = err?.status || err?.response?.status;
70
+ const body = err?.error?.message || err?.message || String(err);
71
+ if (provider === 'glm' && GLM_ROUTE_STATUSES.has(status)) {
72
+ // A genuine 429 (quota or rate limit on the right endpoint) is also
73
+ // possible, so name both causes instead of asserting the key is wrong.
74
+ const advice = status === 429
75
+ ? 'Either that endpoint has no balance/quota left, or the key belongs to the other plan.'
76
+ : 'Check that ZHIPU_API_KEY is valid for that endpoint.';
77
+ return new Error(
78
+ `GLM request for model "${model}" was rejected (HTTP ${status}). ${glmRouteHint(baseUrl)} ` +
79
+ `${advice} Original error: ${body}`,
80
+ { cause: err },
81
+ );
82
+ }
83
+ if (status === 404 || /model.*not.*found|unknown model/i.test(body)) {
84
+ const hint = provider === 'glm'
85
+ ? `→ Pass --provider glm --model glm-5.2. ${glmRouteHint(baseUrl)}\n`
86
+ : '→ Override with --model <name> or set TRISS_WORKER_FLASH_MODEL / TRISS_WORKER_PRO_MODEL in your env.\n' +
87
+ '→ Current DeepSeek model names: deepseek-v4-flash, deepseek-v4-pro.\n';
88
+ return new Error(
89
+ `Model "${model}" not accepted by the provider.\n${hint}Original error: ${body}`,
90
+ { cause: err },
91
+ );
92
+ }
93
+ return err;
94
+ }
95
+
96
+ // A Z.AI key authenticates against exactly one of the two endpoints and
97
+ // carries no marker of which, so a call routed by the `zai-coding-plan`
98
+ // default is a guess. When that guess is rejected with a routing status we
99
+ // retry once on the sibling endpoint and remember the winner for the rest of
100
+ // the process — a long-lived MCP server pays for the discovery at most once
101
+ // per process. Only the endpoint is cached: the API key is never retained or
102
+ // fingerprinted. If the key changes and belongs to the other endpoint, the
103
+ // cached route is rejected and this same one-retry path corrects it.
104
+ let glmDiscoveredBaseUrl = null;
105
+
106
+ // Test seam + escape hatch for a process that swaps keys without restarting.
107
+ export function resetGlmEndpointDiscovery() {
108
+ glmDiscoveredBaseUrl = null;
109
+ }
110
+
111
+ function statusOf(err) {
112
+ return err?.status || err?.response?.status;
113
+ }
114
+
115
+ export async function withGlmEndpointFallback(request, run, deps = {}) {
116
+ const warn = deps.warn || ((line) => process.stderr.write(pc.dim(line)));
117
+ const provisional = request.provider === 'glm' && request.endpointSource === 'default';
118
+ const baseUrl = provisional && glmDiscoveredBaseUrl
119
+ ? glmDiscoveredBaseUrl
120
+ : request.baseUrl;
121
+
30
122
  try {
31
- const resp = await client.chat.completions.create({
32
- model,
33
- messages,
34
- max_tokens: maxTokens,
35
- temperature: temperature ?? 0.2,
36
- });
37
- recordUsage(resp, label || 'chat');
38
- return resp;
123
+ return { result: await run(baseUrl), baseUrl };
39
124
  } catch (err) {
40
- const status = err?.status || err?.response?.status;
41
- const body = err?.error?.message || err?.message || String(err);
42
- if (status === 404 || /model.*not.*found|unknown model/i.test(body)) {
43
- throw new Error(
44
- `Model "${model}" not accepted by the provider.\n` +
45
- `→ Override with --model <name> or set TRISS_WORKER_FLASH_MODEL / TRISS_WORKER_PRO_MODEL in your env.\n` +
46
- `→ Current DeepSeek model names: deepseek-v4-flash, deepseek-v4-pro.\n` +
47
- `Original error: ${body}`,
48
- { cause: err },
49
- );
125
+ const sibling = provisional && GLM_ROUTE_STATUSES.has(statusOf(err))
126
+ ? siblingZaiBaseUrl(baseUrl)
127
+ : null;
128
+ if (!sibling) throw providerRequestError(err, { ...request, baseUrl });
129
+
130
+ let result;
131
+ try {
132
+ result = await run(sibling);
133
+ } catch {
134
+ // The key works on neither endpoint, so the sibling's rejection says
135
+ // nothing new — surface the original one, which already carries the
136
+ // endpoint hint for the route the user actually asked for.
137
+ throw providerRequestError(err, { ...request, baseUrl });
50
138
  }
51
- throw err;
139
+ glmDiscoveredBaseUrl = sibling;
140
+ warn(
141
+ `[triss] ZHIPU_API_KEY was rejected by ${normalizeZaiBaseUrl(baseUrl)} (HTTP ${statusOf(err)}) ` +
142
+ `but works on ${sibling}. Using that endpoint for the rest of this process. ` +
143
+ `Pin it with TRISS_CODER_MODEL=${zaiPrefixForBaseUrl(sibling)}/<model> ` +
144
+ 'or run `triss coder init` to skip this probe.\n',
145
+ );
146
+ return { result, baseUrl: sibling };
52
147
  }
53
148
  }
54
149
 
150
+ export async function chat({
151
+ provider,
152
+ baseUrl,
153
+ model,
154
+ endpointSource,
155
+ messages,
156
+ maxTokens,
157
+ temperature,
158
+ label,
159
+ }) {
160
+ const { result: resp, baseUrl: usedBaseUrl } = await withGlmEndpointFallback(
161
+ { provider, baseUrl, model, endpointSource },
162
+ (url) =>
163
+ getClient({ provider, baseUrl: url }).chat.completions.create({
164
+ model,
165
+ messages,
166
+ max_tokens: maxTokens,
167
+ temperature: temperature ?? 0.2,
168
+ }),
169
+ );
170
+ recordUsage(resp, label || 'chat', { provider, baseUrl: usedBaseUrl, model });
171
+ return resp;
172
+ }
173
+
55
174
  export function reportUsage(resp, label = 'worker') {
56
175
  const u = resp?.usage;
57
176
  if (!u) return '';
@@ -63,31 +182,32 @@ export function reportUsage(resp, label = 'worker') {
63
182
  // from the final chunk (requires stream_options.include_usage). Returns
64
183
  // the full assembled text and the OpenAI-style response shape so callers
65
184
  // can reuse reportUsage().
66
- export async function chatStream({ model, messages, maxTokens, temperature, label, onChunk }) {
67
- const client = getClient();
68
- let stream;
69
- try {
70
- stream = await client.chat.completions.create({
71
- model,
72
- messages,
73
- max_tokens: maxTokens,
74
- temperature: temperature ?? 0.2,
75
- stream: true,
76
- stream_options: { include_usage: true },
77
- });
78
- } catch (err) {
79
- const status = err?.status || err?.response?.status;
80
- const body = err?.error?.message || err?.message || String(err);
81
- if (status === 404 || /model.*not.*found|unknown model/i.test(body)) {
82
- throw new Error(
83
- `Model "${model}" not accepted by the provider.\n` +
84
- `→ Override with --model <name> or set TRISS_WORKER_FLASH_MODEL / TRISS_WORKER_PRO_MODEL.\n` +
85
- `Original error: ${body}`,
86
- { cause: err },
87
- );
88
- }
89
- throw err;
90
- }
185
+ export async function chatStream({
186
+ provider,
187
+ baseUrl,
188
+ model,
189
+ endpointSource,
190
+ messages,
191
+ maxTokens,
192
+ temperature,
193
+ label,
194
+ onChunk,
195
+ }) {
196
+ // The endpoint rejects a mismatched key while opening the stream, so the
197
+ // fallback wraps creation only — nothing has been emitted to the caller yet
198
+ // at that point, which is what makes retrying on the sibling safe.
199
+ const { result: stream, baseUrl: usedBaseUrl } = await withGlmEndpointFallback(
200
+ { provider, baseUrl, model, endpointSource },
201
+ (url) =>
202
+ getClient({ provider, baseUrl: url }).chat.completions.create({
203
+ model,
204
+ messages,
205
+ max_tokens: maxTokens,
206
+ temperature: temperature ?? 0.2,
207
+ stream: true,
208
+ stream_options: { include_usage: true },
209
+ }),
210
+ );
91
211
 
92
212
  let text = '';
93
213
  let lastChunk = null;
@@ -105,6 +225,6 @@ export async function chatStream({ model, messages, maxTokens, temperature, labe
105
225
  choices: [{ message: { content: text }, finish_reason: lastChunk?.choices?.[0]?.finish_reason ?? 'stop' }],
106
226
  usage: lastChunk?.usage,
107
227
  };
108
- recordUsage(fakeResp, label || 'chat-stream');
228
+ recordUsage(fakeResp, label || 'chat-stream', { provider, baseUrl: usedBaseUrl, model });
109
229
  return fakeResp;
110
230
  }