@mmerterden/multi-agent-pipeline 20.19.0 → 20.21.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 +44 -0
- package/README.md +25 -14
- package/README.tr.md +22 -11
- package/docs/ecosystem.md +14 -8
- package/docs/facts.json +7 -6
- package/docs/features.md +2 -1
- package/install/catalog-history.json +1 -1
- package/manifest.json +49 -46
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/route-on/SKILL.md +32 -8
- package/pipeline/commands/multi-agent/route-status/SKILL.md +12 -2
- package/pipeline/commands/multi-agent/setup/SKILL.md +2 -1
- package/pipeline/lib/model-dispatch.sh +72 -24
- package/pipeline/lib/outbound-gate.mjs +2 -0
- package/pipeline/lib/route-state.sh +118 -10
- package/pipeline/lib/toolkit-llm.sh +30 -0
- package/pipeline/lib/unattended-profile-check.mjs +23 -2
- package/pipeline/multi-agent-refs/analysis/evidence.md +3 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +12 -3
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/features/exploratory-testing.md +7 -0
- package/pipeline/multi-agent-refs/features/external-context-injection.md +1 -1
- package/pipeline/multi-agent-refs/features/free-models.md +111 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +21 -15
- package/pipeline/multi-agent-refs/features/unattended-security.md +7 -4
- package/pipeline/multi-agent-refs/refactor/toolkit-research.md +3 -1
- package/pipeline/multi-agent-refs/setup/free-models.md +22 -0
- package/pipeline/preferences-template.json +1 -0
- package/pipeline/schemas/prefs.schema.json +10 -1
- package/pipeline/schemas/route-config.schema.json +33 -8
- package/pipeline/schemas/secret-patterns.json +14 -0
- package/pipeline/schemas/unattended-profile.json +1 -1
- package/pipeline/scripts/bulk-read.sh +98 -12
- package/pipeline/scripts/cost-table.json +9 -1
- package/pipeline/scripts/doctor.mjs +100 -0
- package/pipeline/scripts/pre-commit-check.sh +1 -1
- package/pipeline/scripts/unattended_policy.py +28 -0
- package/pipeline/skills/.skill-manifest.json +8 -8
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +2 -0
- package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +31 -7
- package/pipeline/skills/shared/core/multi-agent-route-status/SKILL.md +12 -2
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -0
- package/pipeline/skills/shared/external/e2e-testing/SKILL.md +39 -7
- package/pipeline/skills/shared/external/signal-community/SKILL.md +7 -1
- package/pipeline/skills/shared/external/web-testing/SKILL.md +57 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Free models: the `free` rung and the `delegate` call site
|
|
2
|
+
|
|
3
|
+
<!-- toc -->
|
|
4
|
+
- [Turning it on](#turning-it-on)
|
|
5
|
+
- [The two call sites](#the-two-call-sites)
|
|
6
|
+
- [What `delegate` may hand over](#what-delegate-may-hand-over)
|
|
7
|
+
- [The contract with the answer](#the-contract-with-the-answer)
|
|
8
|
+
- [Unattended runs](#unattended-runs)
|
|
9
|
+
- [Requirements](#requirements)
|
|
10
|
+
<!-- /toc -->
|
|
11
|
+
|
|
12
|
+
Layer 2 of model routing. With `/multi-agent:route-on --free`, simple text work
|
|
13
|
+
that the pipeline does itself goes to the multi-agent-toolkit's verified
|
|
14
|
+
free-model chain instead of a Claude rung. Decisions, plans, code, reviews,
|
|
15
|
+
triage and security judgements never do.
|
|
16
|
+
|
|
17
|
+
Off by default. Nothing here changes a run until routing and its free chain are
|
|
18
|
+
both on.
|
|
19
|
+
|
|
20
|
+
## Turning it on
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bash "$HOME/.claude/lib/route-state.sh" on --free # Groq and a local Ollama only
|
|
24
|
+
bash "$HOME/.claude/lib/route-state.sh" on --free --allow-training # also Mistral, Gemini, OpenRouter free, Z.AI
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`--free` sets `modelRouting.free.enabled`, adds `bulk-read` and `delegate` to
|
|
28
|
+
`scope`, and lists which providers are usable. One free key is enough to start:
|
|
29
|
+
Groq's (<https://console.groq.com/keys>), stored with `/multi-agent:setup` or
|
|
30
|
+
`credential-store.sh set llm-groq -`, or exported as `GROQ_API_KEY` for the
|
|
31
|
+
server. `/multi-agent:route-status` reports the last seven days: tasks answered
|
|
32
|
+
free and verified, tasks handed back to Claude, and the tokens kept off Claude.
|
|
33
|
+
`/multi-agent:doctor` warns when the switch is on and no provider is usable;
|
|
34
|
+
`--probe` sends each provider one tiny request.
|
|
35
|
+
|
|
36
|
+
**Data policy.** `allowTraining: false` (the default) keeps text to providers
|
|
37
|
+
whose free tier does not train on prompts (Groq) and a local Ollama.
|
|
38
|
+
`--allow-training` adds free tiers that may train on prompts or whose data use
|
|
39
|
+
is not stated. Corporate source, tickets and logs belong under the default.
|
|
40
|
+
|
|
41
|
+
## The two call sites
|
|
42
|
+
|
|
43
|
+
| Call site | Who calls | What goes free | Fallback |
|
|
44
|
+
|---|---|---|---|
|
|
45
|
+
| `bulk-read` | `bulk-read.sh`, when the read gate diverts a large file | the line-numbered summary of one file, validated against `schemas/bulk-read-output.schema.json` and the file's line count | `claude -p --model <bulkRead.model or haiku>` |
|
|
46
|
+
| `delegate` | a skill or phase, through the `llm_delegate` MCP tool | one of the simple tasks below | the session does the task itself |
|
|
47
|
+
|
|
48
|
+
Both ask the router first, so the policy has one implementation:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bash "$HOME/.claude/lib/model-dispatch.sh" delegate --default host # prints `free` or `host`
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`free` means call `llm_delegate`; anything else means do the task in-session as
|
|
55
|
+
before. A subagent is dispatched by the host, so the router never answers `free`
|
|
56
|
+
for one.
|
|
57
|
+
|
|
58
|
+
## What `delegate` may hand over
|
|
59
|
+
|
|
60
|
+
Only tasks whose answer can be checked against their own source:
|
|
61
|
+
|
|
62
|
+
| Task | `llm_delegate` | Development | Analysis |
|
|
63
|
+
|---|---|---|---|
|
|
64
|
+
| Summarize a long text | `task: summarize` | build and test logs, crash logs, a long file the plan only needs the shape of | Confluence pages, Swagger descriptions, Jira descriptions and comment threads, `generic-doc` links fetched through `web_scrape`, research documents |
|
|
65
|
+
| Pull named fields out of a text | `task: extract` with a `schema` | ticket keys, endpoints, error codes, failing test names from a log | acceptance criteria, endpoints and field lists, dates and owners from a document |
|
|
66
|
+
| Pick one label | `task: classify` with `labels` | commit type from a diff summary, a log line's severity | issue type, affected platform, a comment's intent |
|
|
67
|
+
| Translate a draft | `task: translate` | localization drafts for review (the resources repo stays the contract) | a source document into the output language |
|
|
68
|
+
| Write a draft from a source | `task: draft` | a commit body or PR description draft from the diff summary, then the humanizer | a section draft the analysis author then edits |
|
|
69
|
+
|
|
70
|
+
In analysis Phase 1, a fetched `confluence`, `document`, `generic-doc` or
|
|
71
|
+
`jira` text longer than about 8,000 characters is summarized before it is
|
|
72
|
+
reasoned over: ask the router once per run, then `extract` with a schema for
|
|
73
|
+
the fields a section needs (acceptance criteria, endpoints, field lists, dates,
|
|
74
|
+
owners) and `summarize` for the rest. The verified answer is the working copy
|
|
75
|
+
for drafting; the raw fetch stays in its evidence bucket and Section 21 still
|
|
76
|
+
cites the source. `binding: true` standards are never delegated: their wording
|
|
77
|
+
is the requirement.
|
|
78
|
+
|
|
79
|
+
Never handed over: deciding what to build, the plan, any code or diff, review
|
|
80
|
+
findings and triage, security or licence judgements, anything written to a
|
|
81
|
+
ticket, PR or channel without the session reading it first.
|
|
82
|
+
|
|
83
|
+
## The contract with the answer
|
|
84
|
+
|
|
85
|
+
- A verified answer is still a third-party model's text. It comes back fenced
|
|
86
|
+
as untrusted; the session reads it as data and checks it against the plan
|
|
87
|
+
like any other input.
|
|
88
|
+
- `ESCALATE_TO_CLAUDE` is not an error. It lists a reason per provider; the
|
|
89
|
+
session does the task itself and the run continues. Nothing is retried in a
|
|
90
|
+
loop and no weaker answer is substituted.
|
|
91
|
+
- The toolkit scrubs key-shaped strings before anything leaves the machine.
|
|
92
|
+
Text that must not leave the machine at all (credentials, personal data,
|
|
93
|
+
customer records) is not delegated, whatever the policy says.
|
|
94
|
+
|
|
95
|
+
## Unattended runs
|
|
96
|
+
|
|
97
|
+
`llm_delegate` runs in the MCP server, outside the run's OS sandbox, and reads
|
|
98
|
+
provider keys from the credential store; agent-guard allows it only while
|
|
99
|
+
`route-on --free` is on. `bulk-read.sh` runs inside the sandbox, which cannot
|
|
100
|
+
read the Keychain: it uses the free chain only when the provider key is in the
|
|
101
|
+
run's environment, keeps the toolkit's quota and usage files under
|
|
102
|
+
`~/.multi-agent-unattended/toolkit-index`, and reads on haiku otherwise. The
|
|
103
|
+
installer adds the allowed provider hosts to the sandbox network list
|
|
104
|
+
(`lib/unattended-profile-check.mjs` `prefsHosts`) when the free chain is on;
|
|
105
|
+
re-run `install --unattended` after turning it on.
|
|
106
|
+
|
|
107
|
+
## Requirements
|
|
108
|
+
|
|
109
|
+
`@mmerterden/multi-agent-toolkit-mcp` ≥ v3.21.0 (`llm_delegate`,
|
|
110
|
+
`llm_providers`, the `multi-agent-toolkit-llm` CLI). `lib/toolkit-llm.sh` finds
|
|
111
|
+
the CLI through `MULTI_AGENT_TOOLKIT_LLM`, then `PATH`, then `npx`.
|
|
@@ -199,8 +199,9 @@ per-phase `model` field already carries the override).
|
|
|
199
199
|
## Routing at dispatch
|
|
200
200
|
|
|
201
201
|
`$HOME/.claude/lib/model-dispatch.sh` turns `prefs.global.modelRouting` into a
|
|
202
|
-
rung.
|
|
203
|
-
`skills/shared/core/multi-agent/SKILL.md`,
|
|
202
|
+
rung. Three call sites ask it: the subagent dispatch contract in
|
|
203
|
+
`skills/shared/core/multi-agent/SKILL.md`, `bulk-read.sh`, and the `delegate`
|
|
204
|
+
call site for simple text tasks (`features/free-models.md`).
|
|
204
205
|
|
|
205
206
|
Routing is consulted only when the phase named no model. A per-dispatch
|
|
206
207
|
`PHASE_MODEL_OVERRIDE` is a decision the phase made for a reason - Phase 3 sets
|
|
@@ -212,17 +213,22 @@ Two limits are enforced by the script rather than written here and hoped for:
|
|
|
212
213
|
- **A rule may not turn the fable rung on.** With `modelFallback.fableEnabled`
|
|
213
214
|
false, a rule preferring `fable` falls past it to the next rung. Otherwise
|
|
214
215
|
`/multi-agent:model off` would have a second door.
|
|
215
|
-
- **
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
216
|
+
- **A subagent stays inside the Anthropic ladder.** A subagent is dispatched by
|
|
217
|
+
the host, so `free` and every other non-Anthropic rung is refused at the
|
|
218
|
+
subagent site and the rule's next rung answers, with the reason on stderr.
|
|
219
|
+
- **`free` only where the pipeline makes the call, and only when switched on.**
|
|
220
|
+
The `free` rung (provider `free-chain`) answers at `bulk-read` and `delegate`
|
|
221
|
+
while `modelRouting.free.enabled` is true (`route-on --free`), ahead of the
|
|
222
|
+
matched rule's rungs. Its callers keep their own default as the fallback,
|
|
223
|
+
because the toolkit answers `ESCALATE_TO_CLAUDE` whenever no free answer
|
|
224
|
+
survives verification. Any other external rung (the `gpt-*` rows) is refused
|
|
225
|
+
everywhere: no call site speaks it.
|
|
226
|
+
|
|
227
|
+
Strategies change the order of the candidate rungs, never the set: `task-fit`
|
|
228
|
+
tries the cheapest first by `cost-table.json` price, and `cost-ceiling` does the
|
|
229
|
+
same once `cost-budget-check.mjs` reports the run at the warn threshold of
|
|
230
|
+
`budgetCeilingUsd`. `recordDecisions: false` is honoured; `true` (the default)
|
|
231
|
+
logs each decision as `model_routing.decision`.
|
|
226
232
|
|
|
227
233
|
The script never fails and never returns an empty rung: a missing preferences
|
|
228
234
|
file, a missing `jq`, unparseable JSON, an out-of-scope call site and a rung
|
|
@@ -231,8 +237,8 @@ router that can die would turn every call site into a place the run can die, for
|
|
|
231
237
|
a feature that ships disabled.
|
|
232
238
|
|
|
233
239
|
`cost-table.json` rows carry a `provider`, which is what tells the two layers
|
|
234
|
-
apart. `smoke-model-dispatch.sh` drives the script and also asserts that the
|
|
235
|
-
|
|
240
|
+
apart. `smoke-model-dispatch.sh` drives the script and also asserts that the call
|
|
241
|
+
sites invoke it: a correct router nothing calls is the same outage with
|
|
236
242
|
better internals.
|
|
237
243
|
|
|
238
244
|
## Non-goals
|
|
@@ -188,7 +188,7 @@ Under the variable it also blocks:
|
|
|
188
188
|
| Publisher scripts | `jira-publish.sh`, `post-pr-review.sh`, `update-issue-progress.sh`, `analysis-jira-write.sh`, `jira-attach.sh`, `md2confluence-v3.py create/update`, `multi-repo-pipeline.sh push/pr`, `keychain-save.sh`, `credential-store.sh get/set/delete`, `keychain.py get/set/delete`, `gate-ledger.mjs append`, `phone-devices.mjs add/revoke/launch` | the same, a credential never lands in the transcript, gates record verdicts through their own in-process calls (`park` stays allowed: it only stops the run, so there is nothing to forge), and a run never enrols a device that could then command this machine |
|
|
189
189
|
| Git redirection | `git remote add/set-url/rename/remove`; the configuration, environment and subcommands above | each changes what a later command runs or where the runner's push goes |
|
|
190
190
|
| Keychain | `security find-*`, `dump-keychain`, `export`, `add-*password`, `delete-*password`, also behind leading flags (`security -q find-generic-password`) | a run needs no credential it did not get from a fetcher |
|
|
191
|
-
| Network | `curl`/`wget`/`WebFetch`/the toolkit's `web_*` tools to a host outside the allowlist, or to a URL built at run time; `web_eval` outright; every step of `agent_run_steps` judged as if called directly (a nested `agent_*` step or an unreadable step list refused); `ios_open_url` / `android_open_url`, `xcrun simctl openurl` and `open` with a web URL outside the allowlist (an app's own deep-link scheme passes; `open` of anything but an allowed URL is refused); the `research_*` tools outright | an injected "fetch this script" is the first step of most attacks, and a browser opened on a crafted URL sends data out in the query |
|
|
191
|
+
| Network | `curl`/`wget`/`WebFetch`/the toolkit's `web_*` tools to a host outside the allowlist, or to a URL built at run time; `web_eval` outright; every step of `agent_run_steps` judged as if called directly (a nested `agent_*` step or an unreadable step list refused); `ios_open_url` / `android_open_url`, `xcrun simctl openurl` and `open` with a web URL outside the allowlist (an app's own deep-link scheme passes; `open` of anything but an allowed URL is refused); the `research_*` tools outright; `llm_delegate` unless `route-on --free` is on, with its `path` checked like any write target | an injected "fetch this script" is the first step of most attacks, and a browser opened on a crafted URL sends data out in the query |
|
|
192
192
|
| Packages | `npm/pnpm/yarn/bun install|add <pkg>`, `npm install-test/it <pkg>`, `update`, global installs (`yarn global` too), `npx` / `npm exec` / `npm x` of a binary not already in `node_modules/.bin` (with no TTY npm installs it without asking), `npx --yes/--package/--call`, `pnpm dlx`, `pnpx`, `yarn dlx`, `bunx`, `bun x`, `uvx`, `uv tool run/install`, `pipx run/install`, `pip install <pkg>`, `uv/poetry add`, `gem install`, `bundle add`, `brew install`, `cargo add/install`, `go get`, `go install pkg@v`, `swift package add-dependency`, `pod update`; `npm ci`, `pod install` and `pip install -r` once the run changed the manifest they restore from | slopsquatting: a model asked to add a dependency can name one that does not exist yet, and someone can register it |
|
|
193
193
|
| Host changes | `crontab` (except `-l`), `launchctl` (except the read verbs), `defaults` writes, `altool`, `notarytool` | a job or preference outlives the run and runs as the operator; an upload is outward |
|
|
194
194
|
| Manifests | Edit, Write or a shell write to `Package.swift`, `Package.resolved`, `package.json`, lockfiles, `Podfile(.lock)`, `build.gradle(.kts)`, `settings.gradle(.kts)`, `libs.versions.toml`, `requirements*.txt`, `pyproject.toml`, `go.mod`/`go.sum`, `Cargo.toml`/`Cargo.lock`, `Gemfile(.lock)` | the same decision, made through the file instead of the command |
|
|
@@ -231,9 +231,10 @@ allowed host is allowed; `corpDomain` is not used, because it is an email domain
|
|
|
231
231
|
and would allow every host under it. The host is parsed with
|
|
232
232
|
`urllib.parse.urlsplit`, so `http://evil.com#@github.com/` resolves to `evil.com`
|
|
233
233
|
and is refused. The same allowlist governs `WebFetch`, the toolkit's
|
|
234
|
-
`web_goto` / `web_crawl` (any URL argument), each step of `agent_run_steps`,
|
|
234
|
+
`web_goto` / `web_crawl` / `web_map` / `web_scrape` (any URL argument), each step of `agent_run_steps`,
|
|
235
235
|
and a web URL given to `ios_open_url` / `android_open_url`; `web_eval` is
|
|
236
|
-
refused outright because inline JavaScript can reach any host.
|
|
236
|
+
refused outright because inline JavaScript can reach any host. Every entry of a
|
|
237
|
+
`paths` list (`web_upload`) is held to the same path rules as a single path argument. Reads only: a request body or
|
|
237
238
|
upload - `-d`/`--data*`, `-F`/`--form`, `-T`/`--upload-file`, and their attached
|
|
238
239
|
and clustered short forms (`-d@file`, `-sd`, `-Tfile`) - is **refused outright**,
|
|
239
240
|
whatever the host, rather than allowed to an allowlisted one; a run has nothing
|
|
@@ -511,7 +512,9 @@ every process it starts, whatever the command line looks like.
|
|
|
511
512
|
`~/.netrc`, `~/.git-credentials` and `~/.docker/config.json`.
|
|
512
513
|
- `network.strictAllowlist: true` with `allowedDomains` limited to the package
|
|
513
514
|
registries and toolchains the stack adapters need (npm, PyPI, Maven/Google/
|
|
514
|
-
Gradle, CocoaPods, Go, crates, RubyGems, GitHub) plus the prefs hosts
|
|
515
|
+
Gradle, CocoaPods, Go, crates, RubyGems, GitHub) plus the prefs hosts, and,
|
|
516
|
+
while `route-on --free` is on, the free-model hosts its data policy allows
|
|
517
|
+
(`api.groq.com` alone by default; `features/free-models.md`).
|
|
515
518
|
`enableWeakerNetworkIsolation: true` lets Go tools such as `gh` reach the
|
|
516
519
|
macOS trust service to verify TLS; `allowLocalBinding` and
|
|
517
520
|
`allowMachLookup: ["com.apple.coresimulator.*"]` let a dev server and the
|
|
@@ -28,10 +28,12 @@ Loaded on demand by `/multi-agent:refactor` Step 0c. The SKILL.md carries the st
|
|
|
28
28
|
|---|------|---------------|-----------------|
|
|
29
29
|
| 1 | MCP protocol | spec revisions + `@modelcontextprotocol/sdk` releases | protocol features released since the pinned SDK range that the server does not use yet: tool annotations (`readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint`), `outputSchema` + structured content, resource links in results, progress + cancellation, `tools/list_changed`, pagination, elicitation |
|
|
30
30
|
| 2 | Host clients | Claude Code / Copilot CLI / Cursor / Antigravity docs + release notes | per-tool description budget, tool-count ceilings, naming conventions, image and output size limits, permission / allowlist ergonomics |
|
|
31
|
-
| 3 | Peer servers | GitHub search on the same domain terms + `stars:>50` | tool surfaces we lack, conventions peers converged on, and what to discard as out of scope |
|
|
31
|
+
| 3 | Peer servers | GitHub search on the same domain terms + `stars:>50`; standing watchlist: `microsoft/playwright-mcp` (browser tool surface, capability groups), `firecrawl/firecrawl-mcp-server` (scrape / crawl / map / extract formats). Check each peer's license before reading code: AGPL or unlicensed means the idea only | tool surfaces we lack, conventions peers converged on, and what to discard as out of scope |
|
|
32
32
|
| 4 | Wrapped tooling | `xcrun simctl help`, `idb`, `adb`, `xcodebuild`, Playwright release notes, Apple ITMS + App Store Review Guidelines | deprecated flags still in use, new capabilities worth a tool, audit rules that changed |
|
|
33
33
|
| 5 | Field practice | X / Twitter, Reddit, MCP community threads | what server authors actually changed recently (transport choice, output-token diets, sandboxing, error shape) |
|
|
34
34
|
|
|
35
|
+
When the toolkit itself is registered, run the axes through it: `research_ask` with `mode: "pro"` (or `"deep"` for a whole axis) and `recency: "month"` for release-note questions, `research_search` with `provider: "arxiv"` for papers, `web_scrape` with `change_tracking` on a release-notes page to see what moved since the last refactor. WebSearch / WebFetch stay the fallback.
|
|
36
|
+
|
|
35
37
|
**Audit the toolkit against the findings** - run the checks, do not assume:
|
|
36
38
|
|
|
37
39
|
```bash
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Setup Step 3e - free-model keys
|
|
2
|
+
|
|
3
|
+
Runs inside setup Step 3, only when `prefs.global.modelRouting.free.enabled` is
|
|
4
|
+
true (`/multi-agent:route-on --free`). These are the keys the toolkit's
|
|
5
|
+
free-model chain reads; `llm_delegate` and `bulk-read.sh` look for them under
|
|
6
|
+
fixed names, so there is no `keychainMapping` entry.
|
|
7
|
+
|
|
8
|
+
| Key | Provider | Where to get it | Asked |
|
|
9
|
+
|---|---|---|---|
|
|
10
|
+
| `llm-groq` | Groq (free tier does not train on prompts) | https://console.groq.com/keys | always first |
|
|
11
|
+
| `llm-mistral` | Mistral | https://console.mistral.ai/api-keys | only with `modelRouting.free.allowTraining` |
|
|
12
|
+
| `llm-gemini` | Google Gemini | https://aistudio.google.com/apikey | only with `allowTraining` |
|
|
13
|
+
| `llm-openrouter` | OpenRouter, free models only | https://openrouter.ai/keys | only with `allowTraining` |
|
|
14
|
+
| `llm-zai` | Z.AI GLM Flash | https://z.ai/manage-apikey/apikey-list | only with `allowTraining` |
|
|
15
|
+
|
|
16
|
+
Each key is one Token Save Flow pass (`token-save-flow.md`) with the clipboard
|
|
17
|
+
save, `pbpaste | credential-store.sh set "<key>" -`, and no author or host step.
|
|
18
|
+
Skip writes nothing and moves to the next key. After the last pass, run
|
|
19
|
+
`route-state.sh status` so the user sees each saved provider reported ready.
|
|
20
|
+
|
|
21
|
+
A local Ollama needs no key: the user sets `OLLAMA_MODEL` for the server. The
|
|
22
|
+
full contract: `$HOME/.claude/multi-agent-refs/features/free-models.md`.
|
|
@@ -599,10 +599,19 @@
|
|
|
599
599
|
"uniqueItems": true,
|
|
600
600
|
"items": {
|
|
601
601
|
"type": "string",
|
|
602
|
-
"enum": ["subagent", "bulk-read", "research"]
|
|
602
|
+
"enum": ["subagent", "bulk-read", "delegate", "research"]
|
|
603
603
|
},
|
|
604
604
|
"description": "No host-session member, deliberately: rewriting the host's base URL would route the user's whole session, not just this pipeline's calls."
|
|
605
605
|
},
|
|
606
|
+
"free": {
|
|
607
|
+
"type": "object",
|
|
608
|
+
"additionalProperties": false,
|
|
609
|
+
"description": "Layer 2: the verified free-model chain at bulk-read and delegate. Contract: route-config.schema.json.",
|
|
610
|
+
"properties": {
|
|
611
|
+
"enabled": { "type": "boolean", "default": false },
|
|
612
|
+
"allowTraining": { "type": "boolean", "default": false }
|
|
613
|
+
}
|
|
614
|
+
},
|
|
606
615
|
"rules": {
|
|
607
616
|
"type": "array",
|
|
608
617
|
"default": [],
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://github.com/mmerterden/multi-agent-pipeline/pipeline/schemas/route-config.schema.json",
|
|
4
4
|
"title": "Model routing configuration",
|
|
5
|
-
"description": "Policy-driven model selection, OFF by default. Lives under prefs.global.modelRouting. Two layers, and the difference is the whole safety argument. Layer 1 chooses a rung INSIDE the Anthropic ladder and writes to the existing PHASE_MODEL_OVERRIDE seam - no new network path, no new account. Layer 2
|
|
5
|
+
"description": "Policy-driven model selection, OFF by default. Lives under prefs.global.modelRouting. Two layers, and the difference is the whole safety argument. Layer 1 chooses a rung INSIDE the Anthropic ladder and writes to the existing PHASE_MODEL_OVERRIDE seam - no new network path, no new account. Layer 2 is the `free` rung: a simple text task (a bulk read, a summary, an extraction, a label, a translation draft) goes to the multi-agent-toolkit's verified free-model chain (llm_delegate / multi-agent-toolkit-llm), whose every answer is checked against its source and discarded when it does not hold, with the Anthropic default taking over. It is reachable only where the pipeline makes the call itself (`bulk-read`, `delegate`); a subagent is dispatched by the host, so model-dispatch.sh refuses `free` and every other non-Anthropic rung at the subagent site and falls to the rule's next rung. `scope` has no `host-session` member and that absence is enforced here rather than written in prose: rewriting the host's base URL would route the user's ENTIRE session, including work that has nothing to do with this pipeline, through a third layer - breaking the subscription's auth model and silently changing which model answers. Shipping this disabled is the same reasoning as modelFallback.fableEnabled: a cost control that is on by default is not a control.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"required": ["enabled"],
|
|
@@ -16,16 +16,16 @@
|
|
|
16
16
|
"type": "string",
|
|
17
17
|
"enum": ["manual", "task-fit", "cost-ceiling"],
|
|
18
18
|
"default": "manual",
|
|
19
|
-
"description": "manual: only the explicit rules[] apply, nothing is inferred. task-fit:
|
|
19
|
+
"description": "manual: only the explicit rules[] apply, nothing is inferred. task-fit: the matched rule's rungs are tried cheapest first (cost-table.json prices, free first). cost-ceiling: rules apply in order until the run reaches the warn threshold of budgetCeilingUsd, then the cheapest rung is tried first."
|
|
20
20
|
},
|
|
21
21
|
"scope": {
|
|
22
22
|
"type": "array",
|
|
23
23
|
"default": ["subagent"],
|
|
24
24
|
"uniqueItems": true,
|
|
25
|
-
"description": "Where routing is allowed to act. Every member names a call site the pipeline itself owns. There is deliberately no `host-session` member - see the title description.",
|
|
25
|
+
"description": "Where routing is allowed to act. Every member names a call site the pipeline itself owns: subagent (persona dispatch, Anthropic rungs only), bulk-read (bulk-read.sh), delegate (simple text tasks the skills hand to llm_delegate), research. There is deliberately no `host-session` member - see the title description.",
|
|
26
26
|
"items": {
|
|
27
27
|
"type": "string",
|
|
28
|
-
"enum": ["subagent", "bulk-read", "research"]
|
|
28
|
+
"enum": ["subagent", "bulk-read", "delegate", "research"]
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"rules": {
|
|
@@ -42,8 +42,14 @@
|
|
|
42
42
|
"additionalProperties": false,
|
|
43
43
|
"minProperties": 1,
|
|
44
44
|
"properties": {
|
|
45
|
-
"persona": {
|
|
46
|
-
|
|
45
|
+
"persona": {
|
|
46
|
+
"type": "string"
|
|
47
|
+
},
|
|
48
|
+
"phase": {
|
|
49
|
+
"type": "integer",
|
|
50
|
+
"minimum": 0,
|
|
51
|
+
"maximum": 5
|
|
52
|
+
},
|
|
47
53
|
"taskKind": {
|
|
48
54
|
"type": "string",
|
|
49
55
|
"enum": ["bugfix", "feature", "refactor", "chore", "component"]
|
|
@@ -54,16 +60,35 @@
|
|
|
54
60
|
"type": "array",
|
|
55
61
|
"minItems": 1,
|
|
56
62
|
"description": "Rungs in descending preference. Rung NAMES are the contract; model ids are not - they live in cost-table.json and change without a config edit.",
|
|
57
|
-
"items": {
|
|
63
|
+
"items": {
|
|
64
|
+
"type": "string"
|
|
65
|
+
}
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
}
|
|
61
69
|
},
|
|
70
|
+
"free": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"additionalProperties": false,
|
|
73
|
+
"description": "Layer 2. With enabled:true, the bulk-read and delegate call sites try the free rung before the rule's Anthropic rungs. Written by route-on --free.",
|
|
74
|
+
"properties": {
|
|
75
|
+
"enabled": {
|
|
76
|
+
"type": "boolean",
|
|
77
|
+
"default": false,
|
|
78
|
+
"description": "Try the verified free-model chain first at bulk-read and delegate. Ships false."
|
|
79
|
+
},
|
|
80
|
+
"allowTraining": {
|
|
81
|
+
"type": "boolean",
|
|
82
|
+
"default": false,
|
|
83
|
+
"description": "Also use free tiers that may train on prompts (Mistral, Gemini) or whose data use is unknown. false keeps text to providers that do not (Groq) and a local Ollama."
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
62
87
|
"budgetCeilingUsd": {
|
|
63
88
|
"type": ["number", "null"],
|
|
64
89
|
"default": null,
|
|
65
90
|
"minimum": 0,
|
|
66
|
-
"description": "Only consulted by the cost-ceiling strategy. null means no ceiling, which is not the same as a ceiling of 0."
|
|
91
|
+
"description": "Only consulted by the cost-ceiling strategy, through cost-budget-check.mjs against the run's tracker. null means no ceiling, which is not the same as a ceiling of 0."
|
|
67
92
|
},
|
|
68
93
|
"recordDecisions": {
|
|
69
94
|
"type": "boolean",
|
|
@@ -78,6 +78,20 @@
|
|
|
78
78
|
"len": 36,
|
|
79
79
|
"suffix": ""
|
|
80
80
|
},
|
|
81
|
+
{
|
|
82
|
+
"id": "groq",
|
|
83
|
+
"prefix": "gsk_",
|
|
84
|
+
"fill": "A",
|
|
85
|
+
"len": 52,
|
|
86
|
+
"suffix": ""
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"id": "openrouter",
|
|
90
|
+
"prefix": "sk-or-v1-",
|
|
91
|
+
"fill": "a",
|
|
92
|
+
"len": 64,
|
|
93
|
+
"suffix": ""
|
|
94
|
+
},
|
|
81
95
|
{
|
|
82
96
|
"id": "huggingface",
|
|
83
97
|
"prefix": "hf_",
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
[
|
|
145
145
|
"MCP_TOOLKIT_URL_POLICY",
|
|
146
146
|
"strict",
|
|
147
|
-
"web_goto and
|
|
147
|
+
"web_goto, web_crawl, web_map and web_scrape refuse loopback, link-local and metadata addresses"
|
|
148
148
|
],
|
|
149
149
|
[
|
|
150
150
|
"MCP_TOOLKIT_INDEX_DENY",
|
|
@@ -91,11 +91,12 @@ if command -v jq >/dev/null 2>&1; then
|
|
|
91
91
|
fi
|
|
92
92
|
[ -n "$MODEL" ] || MODEL="${PREF_MODEL:-haiku}"
|
|
93
93
|
|
|
94
|
-
# Routing picks the rung
|
|
95
|
-
# `
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
# account that owns it,
|
|
94
|
+
# Routing picks the rung. An Anthropic rung goes to the `claude` CLI below. The
|
|
95
|
+
# `free` rung (route-on --free) goes to the multi-agent-toolkit's verified
|
|
96
|
+
# free-model chain first, and the Anthropic default answers whenever that chain
|
|
97
|
+
# escalates or is unreachable. Sending this read to a free provider puts the
|
|
98
|
+
# file's text outside the account that owns it, which is why it happens only
|
|
99
|
+
# after a user turned it on, and only to providers the data policy allows.
|
|
99
100
|
# An explicit --model still wins: a caller that named a rung asked for that rung.
|
|
100
101
|
if [ -z "${MODEL_EXPLICIT:-}" ] && [ -x "$HERE/../lib/model-dispatch.sh" ]; then
|
|
101
102
|
MODEL=$("$HERE/../lib/model-dispatch.sh" bulk-read \
|
|
@@ -104,7 +105,9 @@ fi
|
|
|
104
105
|
case "$TIMEOUT" in "" ) TIMEOUT="$PREF_TIMEOUT" ;; esac
|
|
105
106
|
case "$TIMEOUT" in ""|*[!0-9]*|0) TIMEOUT=60 ;; esac
|
|
106
107
|
|
|
107
|
-
|
|
108
|
+
FALLBACK_MODEL="${PREF_MODEL:-haiku}"
|
|
109
|
+
[ "$FALLBACK_MODEL" = "free" ] && FALLBACK_MODEL="haiku"
|
|
110
|
+
[ "$MODEL" = "free" ] || command -v claude >/dev/null 2>&1 || degrade "the claude CLI is not on PATH"
|
|
108
111
|
|
|
109
112
|
LINES=$(wc -l < "$FILE" | tr -d ' ')
|
|
110
113
|
BYTES=$(wc -c < "$FILE" | tr -d ' ')
|
|
@@ -163,7 +166,8 @@ fi
|
|
|
163
166
|
|
|
164
167
|
PROMPT_FILE="$(mktemp -t bulk-read.XXXXXX)"
|
|
165
168
|
OUT_FILE="$(mktemp -t bulk-read-out.XXXXXX)"
|
|
166
|
-
|
|
169
|
+
FREE_DIR="$(mktemp -d -t bulk-read-free.XXXXXX)"
|
|
170
|
+
trap 'rm -rf "$PROMPT_FILE" "$OUT_FILE" "$FREE_DIR"' EXIT
|
|
167
171
|
|
|
168
172
|
# The worker gets numbered lines because every claim it makes has to be
|
|
169
173
|
# addressable afterwards. `nl -ba` numbers blank lines too, so the numbers match
|
|
@@ -191,6 +195,90 @@ done
|
|
|
191
195
|
} > "$PROMPT_FILE"
|
|
192
196
|
|
|
193
197
|
START=$(date +%s)
|
|
198
|
+
TIMEOUT_BIN=""
|
|
199
|
+
for candidate in timeout gtimeout; do
|
|
200
|
+
command -v "$candidate" >/dev/null 2>&1 && { TIMEOUT_BIN="$candidate"; break; }
|
|
201
|
+
done
|
|
202
|
+
|
|
203
|
+
# The free chain gets the same persona, question and numbered lines, and the
|
|
204
|
+
# bulk-reader output schema. The toolkit verifies the answer
|
|
205
|
+
# against the numbered text before it returns it; the range check below adds
|
|
206
|
+
# what only this script knows, the file's line count. Anything short of a
|
|
207
|
+
# verified, in-range answer hands the read to the Anthropic default.
|
|
208
|
+
FREE_DONE=0
|
|
209
|
+
if [ "$MODEL" = "free" ]; then
|
|
210
|
+
FREE_REASON=""
|
|
211
|
+
_MA_TL="$(dirname "${BASH_SOURCE[0]}")/../lib/toolkit-llm.sh"
|
|
212
|
+
[ -f "$_MA_TL" ] || _MA_TL="$HOME/.claude/lib/toolkit-llm.sh"
|
|
213
|
+
FREE_SCHEMA="$HERE/../schemas/bulk-read-output.schema.json"
|
|
214
|
+
if [ -f "$_MA_TL" ] && [ -f "$FREE_SCHEMA" ]; then
|
|
215
|
+
. "$_MA_TL"
|
|
216
|
+
# The sandbox of an unattended run denies writes under ~/.claude, where the
|
|
217
|
+
# toolkit keeps its quota and usage files by default.
|
|
218
|
+
if [ "${MULTI_AGENT_UNATTENDED:-}" = "1" ] && [ -z "${MCP_TOOLKIT_INDEX_DIR:-}" ]; then
|
|
219
|
+
export MCP_TOOLKIT_INDEX_DIR="$HOME/.multi-agent-unattended/toolkit-index"
|
|
220
|
+
fi
|
|
221
|
+
nl -ba "$FILE" > "$FREE_DIR/numbered.txt"
|
|
222
|
+
FREE_PROMPT="$(awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$PERSONA")
|
|
223
|
+
<question>
|
|
224
|
+
$QUESTION
|
|
225
|
+
</question>
|
|
226
|
+
The SOURCE is the file $FILE with its $LINES lines numbered."
|
|
227
|
+
ALLOW_FLAG=""
|
|
228
|
+
for prefs in "${MULTI_AGENT_PREFS:-}" "$HOME/.claude/multi-agent-preferences.json" "$HOME/.config/multi-agent-pipeline/multi-agent-preferences.json"; do
|
|
229
|
+
[ -n "$prefs" ] && [ -f "$prefs" ] || continue
|
|
230
|
+
[ "$(jq -r '.global.modelRouting.free.allowTraining // false' "$prefs" 2>/dev/null)" = "true" ] && ALLOW_FLAG="--allow-training"
|
|
231
|
+
break
|
|
232
|
+
done
|
|
233
|
+
if [ -n "$TIMEOUT_BIN" ]; then
|
|
234
|
+
"$TIMEOUT_BIN" "$TIMEOUT" bash -c '. "$1"; shift; ma_toolkit_llm "$@"' _ "$_MA_TL" delegate --task summarize \
|
|
235
|
+
--prompt "$FREE_PROMPT" --path "$FREE_DIR/numbered.txt" --schema "$FREE_SCHEMA" \
|
|
236
|
+
--max-tokens 2048 $ALLOW_FLAG > "$FREE_DIR/result.json" 2>/dev/null
|
|
237
|
+
else
|
|
238
|
+
ma_toolkit_llm delegate --task summarize --prompt "$FREE_PROMPT" --path "$FREE_DIR/numbered.txt" \
|
|
239
|
+
--schema "$FREE_SCHEMA" --max-tokens 2048 $ALLOW_FLAG > "$FREE_DIR/result.json" 2>/dev/null
|
|
240
|
+
fi
|
|
241
|
+
FREE_STATUS=$?
|
|
242
|
+
case "$FREE_STATUS" in
|
|
243
|
+
0)
|
|
244
|
+
if LINES="$LINES" python3 - "$FREE_DIR/result.json" "$OUT_FILE" <<'PYEOF'
|
|
245
|
+
import json, os, sys
|
|
246
|
+
r = json.load(open(sys.argv[1]))
|
|
247
|
+
v = r.get("value")
|
|
248
|
+
n = int(os.environ["LINES"])
|
|
249
|
+
ok = isinstance(v, dict)
|
|
250
|
+
for reg in (v or {}).get("regions") or []:
|
|
251
|
+
ok = ok and 1 <= reg.get("start", 0) <= reg.get("end", 0) <= n
|
|
252
|
+
for sym in (v or {}).get("symbols") or []:
|
|
253
|
+
ok = ok and 1 <= sym.get("line", 0) <= n
|
|
254
|
+
if not ok:
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
json.dump(v, open(sys.argv[2], "w"))
|
|
257
|
+
open(sys.argv[2] + ".via", "w").write("free:%s/%s" % (r.get("provider"), r.get("model")))
|
|
258
|
+
PYEOF
|
|
259
|
+
then
|
|
260
|
+
FREE_DONE=1
|
|
261
|
+
MODEL=$(cat "$OUT_FILE.via"); rm -f "$OUT_FILE.via"
|
|
262
|
+
else
|
|
263
|
+
FREE_REASON="the free answer cited lines outside 1-$LINES"
|
|
264
|
+
fi ;;
|
|
265
|
+
3) FREE_REASON="no free provider produced a verified answer" ;;
|
|
266
|
+
124) FREE_REASON="the free chain did not answer within ${TIMEOUT}s" ;;
|
|
267
|
+
*) FREE_REASON="the free chain is unavailable (exit $FREE_STATUS)" ;;
|
|
268
|
+
esac
|
|
269
|
+
else
|
|
270
|
+
FREE_REASON="toolkit-llm.sh or the bulk-read output schema is not installed"
|
|
271
|
+
fi
|
|
272
|
+
[ -x "$HERE/log-metric.sh" ] && \
|
|
273
|
+
"$HERE/log-metric.sh" "${MULTI_AGENT_TASK_ID:-unknown}" "$PHASE" bulk_read.free \
|
|
274
|
+
outcome="$([ "$FREE_DONE" = 1 ] && echo verified || echo escalated)" reason="${FREE_REASON:-ok}" >/dev/null 2>&1
|
|
275
|
+
if [ "$FREE_DONE" != 1 ]; then
|
|
276
|
+
echo "bulk-read: free chain not used ($FREE_REASON); reading on $FALLBACK_MODEL" >&2
|
|
277
|
+
MODEL="$FALLBACK_MODEL"
|
|
278
|
+
command -v claude >/dev/null 2>&1 || degrade "the claude CLI is not on PATH"
|
|
279
|
+
fi
|
|
280
|
+
fi
|
|
281
|
+
|
|
194
282
|
# `claude -p` reads the prompt from stdin; the model flag names the rung. A
|
|
195
283
|
# non-zero exit, an empty answer and a non-JSON answer are all degradations, and
|
|
196
284
|
# each one names itself rather than falling through to a generic failure.
|
|
@@ -198,11 +286,9 @@ START=$(date +%s)
|
|
|
198
286
|
# GNU spelling would leave the budget silently unenforced on the platform most of
|
|
199
287
|
# these runs happen on - a pref that does nothing, which is the class
|
|
200
288
|
# smoke-prefs-consumed exists to catch.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
done
|
|
205
|
-
if [ -n "$TIMEOUT_BIN" ]; then
|
|
289
|
+
if [ "$FREE_DONE" = 1 ]; then
|
|
290
|
+
STATUS=0
|
|
291
|
+
elif [ -n "$TIMEOUT_BIN" ]; then
|
|
206
292
|
"$TIMEOUT_BIN" "$TIMEOUT" claude -p --model "$MODEL" < "$PROMPT_FILE" > "$OUT_FILE" 2>/dev/null
|
|
207
293
|
STATUS=$?
|
|
208
294
|
else
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_readme": "Per-model unit prices in USD per million tokens. Source: Anthropic public pricing (Claude-family rows verified 2026-07-28 against the current model table; the gpt-* rows remain approximate). Update when Anthropic publishes new tiers. Rung names (fable / opus / sonnet / haiku) are the pipeline's stable identifiers and `modelId` is the wire value they currently resolve to - dispatch reads the rung, so a generation move is a `modelId` edit here plus the phase specs, never a rename of the rungs. `provider` says which layer a rung belongs to: an `anthropic` rung is reachable from every call site
|
|
2
|
+
"_readme": "Per-model unit prices in USD per million tokens. Source: Anthropic public pricing (Claude-family rows verified 2026-07-28 against the current model table; the gpt-* rows remain approximate). Update when Anthropic publishes new tiers. Rung names (fable / opus / sonnet / haiku) are the pipeline's stable identifiers and `modelId` is the wire value they currently resolve to - dispatch reads the rung, so a generation move is a `modelId` edit here plus the phase specs, never a rename of the rungs. `provider` says which layer a rung belongs to: an `anthropic` rung is reachable from every call site; `free-chain` (the `free` rung) only from bulk-read and delegate, where the multi-agent-toolkit's verified free-model chain answers and the caller's default takes over when it escalates; any other provider from no call site. model-dispatch.sh enforces this. Unknown models render USD as ' - ' and emit a footnote - never block PR-body generation. cacheReadPerMtok is the discounted rate for prompt-cache hits (~10% of inPerMtok); the renderer prices a phase's tokens_cached at this rate when the tracker records it, so resume/cache reuse is visible in the ledger.",
|
|
3
3
|
"schemaVersion": "1.1.0",
|
|
4
4
|
"prices": {
|
|
5
5
|
"fable": {
|
|
@@ -57,6 +57,14 @@
|
|
|
57
57
|
"modelId": "gpt-5.6-terra",
|
|
58
58
|
"provider": "openai",
|
|
59
59
|
"note": "Codex CLI floor tier - speed-optimised, maps the haiku rung of the Codex persona tier map (task-clarifier). Approximate; verify against OpenAI pricing before relying on totals."
|
|
60
|
+
},
|
|
61
|
+
"free": {
|
|
62
|
+
"inPerMtok": 0.0,
|
|
63
|
+
"outPerMtok": 0.0,
|
|
64
|
+
"cacheReadPerMtok": 0.0,
|
|
65
|
+
"modelId": "free-chain",
|
|
66
|
+
"provider": "free-chain",
|
|
67
|
+
"note": "Layer 2: the multi-agent-toolkit's verified free-model chain (llm_delegate / multi-agent-toolkit-llm) over free tiers - Groq, Mistral, Gemini, OpenRouter free models, Z.AI GLM Flash, local Ollama. Priced at zero because the provider bills nothing on a free tier; what it saves is reported by route-status from the toolkit's usage log at the rate of the rung it replaced."
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
}
|