Compare commits

...

17 Commits

Author SHA1 Message Date
Peter Steinberger
a36dc285d6 fix: guard console settings recursion (#1555) (thanks @travisp) 2026-01-24 03:12:19 +00:00
Travis
704a3ba51c Logging: guard console settings recursion 2026-01-24 03:06:34 +00:00
Peter Steinberger
b6591c3f69 fix: add log hint for agent failure (#1550) (thanks @sweepies) 2026-01-24 02:56:38 +00:00
google-labs-jules[bot]
e6fdbae79b Fix formatting of 'Agent failed before reply' error messages
- Remove hardcoded period after error message to avoid double periods
- Move 'Check gateway logs for details' to a new line for better readability
2026-01-24 02:54:42 +00:00
Peter Steinberger
a4e57d3ac4 fix: align service path tests with platform delimiters 2026-01-24 02:34:54 +00:00
Peter Steinberger
1d862cf5c2 fix: add readability fallback extraction 2026-01-24 02:15:13 +00:00
Peter Steinberger
0840029982 fix: stabilize embedded runner queueing 2026-01-24 02:05:41 +00:00
Peter Steinberger
309fcc5321 fix: publish llm-task docs and harden tool 2026-01-24 01:44:51 +00:00
Peter Steinberger
00ae21bed2 fix: inline auth probe errors in status table 2026-01-24 01:37:08 +00:00
Peter Steinberger
00fd57b8f5 fix: honor wildcard tool allowlists 2026-01-24 01:30:44 +00:00
Peter Steinberger
aabe0bed30 fix: clean wrapped banner tagline 2026-01-24 01:26:17 +00:00
Peter Steinberger
350131b4d7 fix: improve web image optimization 2026-01-24 01:18:58 +00:00
Vignesh
95d45c0aa7 feat: add optional llm-task JSON-only tool (#1498)
* feat(llm-task): add optional JSON-only LLM task tool

* fix(llm-task): fix invalid package.json

* fix(llm-task): fix invalid plugin manifest JSON

* fix(llm-task): fix index.ts import quoting

* fix(llm-task): load embedded runner from src or bundled dist
2026-01-24 01:18:47 +00:00
Peter Steinberger
cb06e133ca docs: update bedrock discovery changelog ref (#1553) (thanks @fal3) 2026-01-24 01:18:33 +00:00
Peter Steinberger
4e77483051 fix: refine bedrock discovery defaults (#1543) (thanks @fal3) 2026-01-24 01:18:33 +00:00
Peter Steinberger
81535d512a fix: clarify auth order exclusions 2026-01-24 01:18:03 +00:00
Alex Fallah
8effb557d5 feat: add dynamic Bedrock model discovery
Add automatic discovery of AWS Bedrock models using ListFoundationModels API.
When AWS credentials are detected, models that support streaming and text output
are automatically discovered and made available.

- Add @aws-sdk/client-bedrock dependency
- Add discoverBedrockModels() with caching (default 1 hour)
- Add resolveImplicitBedrockProvider() for auto-registration
- Add BedrockDiscoveryConfig for optional filtering by provider/region
- Filter to active, streaming, text-output models only
- Update docs/bedrock.md with auto-discovery documentation
2026-01-24 01:15:06 +00:00
50 changed files with 2373 additions and 278 deletions

View File

@@ -5,16 +5,20 @@ Docs: https://docs.clawd.bot
## 2026.1.23 (Unreleased)
### Changes
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
### Fixes
- Logging: guard console settings resolution to avoid recursion on config warnings. (#1555) Thanks @travisp.
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
@@ -25,6 +29,13 @@ Docs: https://docs.clawd.bot
- CLI: render auth probe results as a table in `clawdbot models status`.
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
- CLI: move auth probe errors below the table to reduce wrapping.
- CLI: prevent ANSI color bleed when table cells wrap.
- CLI: explain when auth profiles are excluded by auth.order in probe details.
- CLI: drop the em dash when the banner tagline wraps to a second line.
- CLI: inline auth probe errors in status rows to reduce wrapping.
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
- Daemon: use platform PATH delimiters when building minimal service paths.
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.

View File

@@ -480,27 +480,27 @@ Thanks to all clawtributors:
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a>
<a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
<a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a>
<a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a>
<a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
<a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a>
<a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a>
<a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
<a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a>
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
<a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a>
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
<a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
<a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a>
<a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a>
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a>
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a>
<a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a>
<a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a>
<a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
<a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a>
<a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a>
<a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a>
<a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
<a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
<a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a>
<a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
<a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
<a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a>
<a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>

View File

@@ -17,6 +17,37 @@ not an API key.
- Auth: AWS credentials (env vars, shared config, or instance role)
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
## Automatic model discovery
If AWS credentials are detected, Clawdbot can automatically discover Bedrock
models that support **streaming** and **text output**. Discovery uses
`bedrock:ListFoundationModels` and is cached (default: 1 hour).
Config options live under `models.bedrockDiscovery`:
```json5
{
models: {
bedrockDiscovery: {
enabled: true,
region: "us-east-1",
providerFilter: ["anthropic", "amazon"],
refreshInterval: 3600,
defaultContextWindow: 32000,
defaultMaxTokens: 4096
}
}
}
```
Notes:
- `enabled` defaults to `true` when AWS credentials are present.
- `region` defaults to `AWS_REGION` or `AWS_DEFAULT_REGION`, then `us-east-1`.
- `providerFilter` matches Bedrock provider names (for example `anthropic`).
- `refreshInterval` is seconds; set to `0` to disable caching.
- `defaultContextWindow` (default: `32000`) and `defaultMaxTokens` (default: `4096`)
are used for discovered models (override if you know your model limits).
## Setup (manual)
1) Ensure AWS credentials are available on the **gateway host**:
@@ -67,6 +98,7 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
## Notes
- Bedrock requires **model access** enabled in your AWS account/region.
- Automatic discovery needs the `bedrock:ListFoundationModels` permission.
- If you use profiles, set `AWS_PROFILE` on the gateway host.
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the

View File

@@ -1000,6 +1000,8 @@
"group": "Tools & Skills",
"pages": [
"tools",
"tools/lobster",
"tools/llm-task",
"plugin",
"plugins/voice-call",
"plugins/zalouser",

View File

@@ -1970,6 +1970,7 @@ Example (provider/model-specific allowlist):
```
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
Matching is case-insensitive and supports `*` wildcards (`"*"` means all tools).
This is applied even when the Docker sandbox is **off**.
Example (disable browser/canvas everywhere):

View File

@@ -22,6 +22,10 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
}
```
Notes:
- Matching is case-insensitive.
- `*` wildcards are supported (`"*"` means all tools).
## Tool profiles (base allowlist)
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
@@ -156,6 +160,7 @@ alongside tools (for example, the voice-call plugin).
Optional plugin tools:
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
- [LLM Task](/tools/llm-task): JSON-only LLM step for structured workflow output (optional schema validation).
## Tool inventory

114
docs/tools/llm-task.md Normal file
View File

@@ -0,0 +1,114 @@
---
summary: "JSON-only LLM tasks for workflows (optional plugin tool)"
read_when:
- You want a JSON-only LLM step inside workflows
- You need schema-validated LLM output for automation
---
# LLM Task
`llm-task` is an **optional plugin tool** that runs a JSON-only LLM task and
returns structured output (optionally validated against JSON Schema).
This is ideal for workflow engines like Lobster: you can add a single LLM step
without writing custom Clawdbot code for each workflow.
## Enable the plugin
1) Enable the plugin:
```json
{
"plugins": {
"entries": {
"llm-task": { "enabled": true }
}
}
}
```
2) Allowlist the tool (it is registered with `optional: true`):
```json
{
"agents": {
"list": [
{
"id": "main",
"tools": { "allow": ["llm-task"] }
}
]
}
}
```
## Config (optional)
```json
{
"plugins": {
"entries": {
"llm-task": {
"enabled": true,
"config": {
"defaultProvider": "openai-codex",
"defaultModel": "gpt-5.2",
"defaultAuthProfileId": "main",
"allowedModels": ["openai-codex/gpt-5.2"],
"maxTokens": 800,
"timeoutMs": 30000
}
}
}
}
}
```
`allowedModels` is an allowlist of `provider/model` strings. If set, any request
outside the list is rejected.
## Tool parameters
- `prompt` (string, required)
- `input` (any, optional)
- `schema` (object, optional JSON Schema)
- `provider` (string, optional)
- `model` (string, optional)
- `authProfileId` (string, optional)
- `temperature` (number, optional)
- `maxTokens` (number, optional)
- `timeoutMs` (number, optional)
## Output
Returns `details.json` containing the parsed JSON (and validates against
`schema` when provided).
## Example: Lobster workflow step
```lobster
clawd.invoke --tool llm-task --action json --args-json '{
"prompt": "Given the input email, return intent and draft.",
"input": {
"subject": "Hello",
"body": "Can you help?"
},
"schema": {
"type": "object",
"properties": {
"intent": { "type": "string" },
"draft": { "type": "string" }
},
"required": ["intent", "draft"],
"additionalProperties": false
}
}'
```
## Safety notes
- The tool is **JSON-only** and instructs the model to output only JSON (no
code fences, no commentary).
- No tools are exposed to the model for this run.
- Treat output as untrusted unless you validate with `schema`.
- Put approvals before any side-effecting step (send, post, exec).

View File

@@ -65,6 +65,52 @@ gog.gmail.search --query 'newer_than:1d' \
| clawd.invoke --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'
```
## JSON-only LLM steps (llm-task)
For workflows that need a **structured LLM step**, enable the optional
`llm-task` plugin tool and call it from Lobster. This keeps the workflow
deterministic while still letting you classify/summarize/draft with a model.
Enable the tool:
```json
{
"plugins": {
"entries": {
"llm-task": { "enabled": true }
}
},
"agents": {
"list": [
{
"id": "main",
"tools": { "allow": ["llm-task"] }
}
]
}
}
```
Use it in a pipeline:
```lobster
clawd.invoke --tool llm-task --action json --args-json '{
"prompt": "Given the input email, return intent and draft.",
"input": { "subject": "Hello", "body": "Can you help?" },
"schema": {
"type": "object",
"properties": {
"intent": { "type": "string" },
"draft": { "type": "string" }
},
"required": ["intent", "draft"],
"additionalProperties": false
}
}'
```
See [LLM Task](/tools/llm-task) for details and configuration options.
## Workflow files (.lobster)
Lobster can run YAML/JSON workflow files with `name`, `args`, `steps`, `env`, `condition`, and `approval` fields. In Clawdbot tool calls, set `pipeline` to the file path.

View File

@@ -0,0 +1,97 @@
# LLM Task (plugin)
Adds an **optional** agent tool `llm-task` for running **JSON-only** LLM tasks
(drafting, summarizing, classifying) with optional JSON Schema validation.
Designed to be called from workflow engines (for example, Lobster via
`clawd.invoke --each`) without adding new Clawdbot code per workflow.
## Enable
1) Enable the plugin:
```json
{
"plugins": {
"entries": {
"llm-task": { "enabled": true }
}
}
}
```
2) Allowlist the tool (it is registered with `optional: true`):
```json
{
"agents": {
"list": [
{
"id": "main",
"tools": { "allow": ["llm-task"] }
}
]
}
}
```
## Config (optional)
```json
{
"plugins": {
"entries": {
"llm-task": {
"enabled": true,
"config": {
"defaultProvider": "openai-codex",
"defaultModel": "gpt-5.2",
"defaultAuthProfileId": "main",
"allowedModels": ["openai-codex/gpt-5.2"],
"maxTokens": 800,
"timeoutMs": 30000
}
}
}
}
}
```
`allowedModels` is an allowlist of `provider/model` strings. If set, any request
outside the list is rejected.
## Tool API
### Parameters
- `prompt` (string, required)
- `input` (any, optional)
- `schema` (object, optional JSON Schema)
- `provider` (string, optional)
- `model` (string, optional)
- `authProfileId` (string, optional)
- `temperature` (number, optional)
- `maxTokens` (number, optional)
- `timeoutMs` (number, optional)
### Output
Returns `details.json` containing the parsed JSON (and validates against
`schema` when provided).
## Notes
- The tool is **JSON-only** and instructs the model to output only JSON
(no code fences, no commentary).
- No tools are exposed to the model for this run.
- Side effects should be handled outside this tool (for example, approvals in
Lobster) before calling tools that send messages/emails.
## Bundled extension note
This extension depends on Clawdbot internal modules (the embedded agent runner).
It is intended to ship as a **bundled** Clawdbot extension (like `lobster`) and
be enabled via `plugins.entries` + tool allowlists.
It is **not** currently designed to be copied into
`~/.clawdbot/extensions` as a standalone plugin directory.

View File

@@ -0,0 +1,21 @@
{
"id": "llm-task",
"name": "LLM Task",
"description": "Generic JSON-only LLM tool for structured tasks callable from workflows.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"defaultProvider": { "type": "string" },
"defaultModel": { "type": "string" },
"defaultAuthProfileId": { "type": "string" },
"allowedModels": {
"type": "array",
"items": { "type": "string" },
"description": "Allowlist of provider/model keys like openai-codex/gpt-5.2."
},
"maxTokens": { "type": "number" },
"timeoutMs": { "type": "number" }
}
}
}

View File

@@ -0,0 +1,7 @@
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
import { createLlmTaskTool } from "./src/llm-task-tool.js";
export default function register(api: ClawdbotPluginApi) {
api.registerTool(createLlmTaskTool(api), { optional: true });
}

View File

@@ -0,0 +1,11 @@
{
"name": "@clawdbot/llm-task",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot JSON-only LLM task plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../../src/agents/pi-embedded-runner.js", () => {
return {
runEmbeddedPiAgent: vi.fn(async () => ({
meta: { startedAt: Date.now() },
payloads: [{ text: "{}" }],
})),
};
});
import { runEmbeddedPiAgent } from "../../../src/agents/pi-embedded-runner.js";
import { createLlmTaskTool } from "./llm-task-tool.js";
function fakeApi(overrides: any = {}) {
return {
id: "llm-task",
name: "llm-task",
source: "test",
config: { agents: { defaults: { workspace: "/tmp", model: { primary: "openai-codex/gpt-5.2" } } } },
pluginConfig: {},
runtime: { version: "test" },
logger: { debug() {}, info() {}, warn() {}, error() {} },
registerTool() {},
...overrides,
};
}
describe("llm-task tool (json-only)", () => {
beforeEach(() => vi.clearAllMocks());
it("returns parsed json", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
});
const tool = createLlmTaskTool(fakeApi() as any);
const res = await tool.execute("id", { prompt: "return foo" });
expect((res as any).details.json).toEqual({ foo: "bar" });
});
it("strips fenced json", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: "```json\n{\"ok\":true}\n```" }],
});
const tool = createLlmTaskTool(fakeApi() as any);
const res = await tool.execute("id", { prompt: "return ok" });
expect((res as any).details.json).toEqual({ ok: true });
});
it("validates schema", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
});
const tool = createLlmTaskTool(fakeApi() as any);
const schema = {
type: "object",
properties: { foo: { type: "string" } },
required: ["foo"],
additionalProperties: false,
};
const res = await tool.execute("id", { prompt: "return foo", schema });
expect((res as any).details.json).toEqual({ foo: "bar" });
});
it("throws on invalid json", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({ meta: {}, payloads: [{ text: "not-json" }] });
const tool = createLlmTaskTool(fakeApi() as any);
await expect(tool.execute("id", { prompt: "x" })).rejects.toThrow(/invalid json/i);
});
it("throws on schema mismatch", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ foo: 1 }) }],
});
const tool = createLlmTaskTool(fakeApi() as any);
const schema = { type: "object", properties: { foo: { type: "string" } }, required: ["foo"] };
await expect(tool.execute("id", { prompt: "x", schema })).rejects.toThrow(/match schema/i);
});
it("passes provider/model overrides to embedded runner", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ ok: true }) }],
});
const tool = createLlmTaskTool(fakeApi() as any);
await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" });
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
expect(call.provider).toBe("anthropic");
expect(call.model).toBe("claude-4-sonnet");
});
it("enforces allowedModels", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ ok: true }) }],
});
const tool = createLlmTaskTool(fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }) as any);
await expect(tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" })).rejects.toThrow(
/not allowed/i,
);
});
it("disables tools for embedded run", async () => {
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
meta: {},
payloads: [{ text: JSON.stringify({ ok: true }) }],
});
const tool = createLlmTaskTool(fakeApi() as any);
await tool.execute("id", { prompt: "x" });
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
expect(call.disableTools).toBe(true);
});
});

View File

@@ -0,0 +1,218 @@
import os from "node:os";
import path from "node:path";
import fs from "node:fs/promises";
import Ajv from "ajv";
import { Type } from "@sinclair/typebox";
// NOTE: This extension is intended to be bundled with Clawdbot.
// When running from source (tests/dev), Clawdbot internals live under src/.
// When running from a built install, internals live under dist/ (no src/ tree).
// So we resolve internal imports dynamically with src-first, dist-fallback.
import type { ClawdbotPluginApi } from "../../../src/plugins/types.js";
type RunEmbeddedPiAgentFn = (params: Record<string, unknown>) => Promise<unknown>;
async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
// Source checkout (tests/dev)
try {
const mod = await import("../../../src/agents/pi-embedded-runner.js");
if (typeof (mod as any).runEmbeddedPiAgent === "function") return (mod as any).runEmbeddedPiAgent;
} catch {
// ignore
}
// Bundled install (built)
const mod = await import("../../../agents/pi-embedded-runner.js");
if (typeof (mod as any).runEmbeddedPiAgent !== "function") {
throw new Error("Internal error: runEmbeddedPiAgent not available");
}
return (mod as any).runEmbeddedPiAgent;
}
function stripCodeFences(s: string): string {
const trimmed = s.trim();
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (m) return (m[1] ?? "").trim();
return trimmed;
}
function collectText(payloads: Array<{ text?: string; isError?: boolean }> | undefined): string {
const texts = (payloads ?? [])
.filter((p) => !p.isError && typeof p.text === "string")
.map((p) => p.text ?? "");
return texts.join("\n").trim();
}
function toModelKey(provider?: string, model?: string): string | undefined {
const p = provider?.trim();
const m = model?.trim();
if (!p || !m) return undefined;
return `${p}/${m}`;
}
type PluginCfg = {
defaultProvider?: string;
defaultModel?: string;
defaultAuthProfileId?: string;
allowedModels?: string[];
maxTokens?: number;
timeoutMs?: number;
};
export function createLlmTaskTool(api: ClawdbotPluginApi) {
return {
name: "llm-task",
description:
"Run a generic JSON-only LLM task and return schema-validated JSON. Designed for orchestration from Lobster workflows via clawd.invoke.",
parameters: Type.Object({
prompt: Type.String({ description: "Task instruction for the LLM." }),
input: Type.Optional(Type.Unknown({ description: "Optional input payload for the task." })),
schema: Type.Optional(Type.Unknown({ description: "Optional JSON Schema to validate the returned JSON." })),
provider: Type.Optional(Type.String({ description: "Provider override (e.g. openai-codex, anthropic)." })),
model: Type.Optional(Type.String({ description: "Model id override." })),
authProfileId: Type.Optional(Type.String({ description: "Auth profile override." })),
temperature: Type.Optional(Type.Number({ description: "Best-effort temperature override." })),
maxTokens: Type.Optional(Type.Number({ description: "Best-effort maxTokens override." })),
timeoutMs: Type.Optional(Type.Number({ description: "Timeout for the LLM run." })),
}),
async execute(_id: string, params: Record<string, unknown>) {
const prompt = String(params.prompt ?? "");
if (!prompt.trim()) throw new Error("prompt required");
const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg;
const primary = api.config?.agents?.defaults?.model?.primary;
const primaryProvider = typeof primary === "string" ? primary.split("/")[0] : undefined;
const primaryModel = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined;
const provider =
(typeof params.provider === "string" && params.provider.trim()) ||
(typeof pluginCfg.defaultProvider === "string" && pluginCfg.defaultProvider.trim()) ||
primaryProvider ||
undefined;
const model =
(typeof params.model === "string" && params.model.trim()) ||
(typeof pluginCfg.defaultModel === "string" && pluginCfg.defaultModel.trim()) ||
primaryModel ||
undefined;
const authProfileId =
(typeof (params as any).authProfileId === "string" && (params as any).authProfileId.trim()) ||
(typeof pluginCfg.defaultAuthProfileId === "string" && pluginCfg.defaultAuthProfileId.trim()) ||
undefined;
const modelKey = toModelKey(provider, model);
if (!provider || !model || !modelKey) {
throw new Error(
`provider/model could not be resolved (provider=${String(provider ?? "")}, model=${String(model ?? "")})`,
);
}
const allowed = Array.isArray(pluginCfg.allowedModels) ? pluginCfg.allowedModels : undefined;
if (allowed && allowed.length > 0 && !allowed.includes(modelKey)) {
throw new Error(
`Model not allowed by llm-task plugin config: ${modelKey}. Allowed models: ${allowed.join(", ")}`,
);
}
const timeoutMs =
(typeof params.timeoutMs === "number" && params.timeoutMs > 0 ? params.timeoutMs : undefined) ||
(typeof pluginCfg.timeoutMs === "number" && pluginCfg.timeoutMs > 0 ? pluginCfg.timeoutMs : undefined) ||
30_000;
const streamParams = {
temperature: typeof params.temperature === "number" ? params.temperature : undefined,
maxTokens:
typeof params.maxTokens === "number"
? params.maxTokens
: typeof pluginCfg.maxTokens === "number"
? pluginCfg.maxTokens
: undefined,
};
const input = (params as any).input as unknown;
let inputJson: string;
try {
inputJson = JSON.stringify(input ?? null, null, 2);
} catch {
throw new Error("input must be JSON-serializable");
}
const system = [
"You are a JSON-only function.",
"Return ONLY a valid JSON value.",
"Do not wrap in markdown fences.",
"Do not include commentary.",
"Do not call tools.",
].join(" ");
const fullPrompt = `${system}\n\nTASK:\n${prompt}\n\nINPUT_JSON:\n${inputJson}\n`;
let tmpDir: string | null = null;
try {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-llm-task-"));
const sessionId = `llm-task-${Date.now()}`;
const sessionFile = path.join(tmpDir, "session.json");
const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent();
const result = await runEmbeddedPiAgent({
sessionId,
sessionFile,
workspaceDir: api.config?.agents?.defaults?.workspace ?? process.cwd(),
config: api.config,
prompt: fullPrompt,
timeoutMs,
runId: `llm-task-${Date.now()}`,
provider,
model,
authProfileId,
authProfileIdSource: authProfileId ? "user" : "auto",
streamParams,
disableTools: true,
});
const text = collectText((result as any).payloads);
if (!text) throw new Error("LLM returned empty output");
const raw = stripCodeFences(text);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("LLM returned invalid JSON");
}
const schema = (params as any).schema as unknown;
if (schema && typeof schema === "object" && !Array.isArray(schema)) {
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(schema as any);
const ok = validate(parsed);
if (!ok) {
const msg =
validate.errors?.map((e) => `${e.instancePath || "<root>"} ${e.message || "invalid"}`).join("; ") ??
"invalid";
throw new Error(`LLM JSON did not match schema: ${msg}`);
}
}
return {
content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }],
details: { json: parsed, provider, model },
};
} finally {
if (tmpDir) {
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {
// ignore
}
}
}
},
};
}

View File

@@ -147,6 +147,7 @@
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.13.0",
"@aws-sdk/client-bedrock": "^3.975.0",
"@buape/carbon": "0.14.0",
"@clack/prompts": "^0.11.0",
"@grammyjs/runner": "^2.0.3",

589
pnpm-lock.yaml generated
View File

@@ -16,6 +16,9 @@ importers:
'@agentclientprotocol/sdk':
specifier: 0.13.0
version: 0.13.0(zod@4.3.5)
'@aws-sdk/client-bedrock':
specifier: ^3.975.0
version: 3.975.0
'@buape/carbon':
specifier: 0.14.0
version: 0.14.0(hono@4.11.4)
@@ -301,6 +304,8 @@ importers:
extensions/imessage: {}
extensions/llm-task: {}
extensions/lobster: {}
extensions/matrix:
@@ -308,9 +313,6 @@ importers:
'@matrix-org/matrix-sdk-crypto-nodejs':
specifier: ^0.4.0
version: 0.4.0
clawdbot:
specifier: workspace:*
version: link:../..
markdown-it:
specifier: 14.1.0
version: 14.1.0
@@ -320,6 +322,13 @@ importers:
music-metadata:
specifier: ^11.10.6
version: 11.10.6
zod:
specifier: ^4.3.5
version: 4.3.5
devDependencies:
clawdbot:
specifier: workspace:*
version: link:../..
extensions/mattermost: {}
@@ -493,46 +502,90 @@ packages:
resolution: {integrity: sha512-rzSuqgMkL488bR9TnZEALBa+SV1FfR3B7CkYvs6R5uZm2AqBMfq7xNZR/pgMiAH/YLlI9FWAh1aPmdnG7iXxnA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-bedrock@3.975.0':
resolution: {integrity: sha512-rA30CX0zcTGKx0S8JSyASVKFYTdQmkDkpkE5o1Mv4j3RmLcp7J2/WeYGVLjWprkNjlAlfpxG3V9VqPsayQ3LzA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-sso@3.972.0':
resolution: {integrity: sha512-5qw6qLiRE4SUiz0hWy878dSR13tSVhbTWhsvFT8mGHe37NRRiaobm5MA2sWD0deRAuO98djSiV+dhWXa1xIFNw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-sso@3.974.0':
resolution: {integrity: sha512-ci+GiM0c4ULo4D79UMcY06LcOLcfvUfiyt8PzNY0vbt5O8BfCPYf4QomwVgkNcLLCYmroO4ge2Yy1EsLUlcD6g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.972.0':
resolution: {integrity: sha512-nEeUW2M9F+xdIaD98F5MBcQ4ITtykj3yKbgFZ6J0JtL3bq+Z90szQ6Yy8H/BLPYXTs3V4n9ifnBo8cprRDiE6A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.973.1':
resolution: {integrity: sha512-Ocubx42QsMyVs9ANSmFpRm0S+hubWljpPLjOi9UFrtcnVJjrVJTzQ51sN0e5g4e8i8QZ7uY73zosLmgYL7kZTQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.0':
resolution: {integrity: sha512-kKHoNv+maHlPQOAhYamhap0PObd16SAb3jwaY0KYgNTiSbeXlbGUZPLioo9oA3wU10zItJzx83ClU7d7h40luA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.1':
resolution: {integrity: sha512-/etNHqnx96phy/SjI0HRC588o4vKH5F0xfkZ13yAATV7aNrb+5gYGNE6ePWafP+FuZ3HkULSSlJFj0AxgrAqYw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.0':
resolution: {integrity: sha512-xzEi81L7I5jGUbpmqEHCe7zZr54hCABdj4H+3LzktHYuovV/oqnvoDdvZpGFR0e/KAw1+PL38NbGrpG30j6qlA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.2':
resolution: {integrity: sha512-mXgdaUfe5oM+tWKyeZ7Vh/iQ94FrkMky1uuzwTOmFADiRcSk5uHy/e3boEFedXiT/PRGzgBmqvJVK4F6lUISCg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.972.0':
resolution: {integrity: sha512-ruhAMceUIq2aknFd3jhWxmO0P0Efab5efjyIXOkI9i80g+zDY5VekeSxfqRKStEEJSKSCHDLQuOu0BnAn4Rzew==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.972.1':
resolution: {integrity: sha512-OdbJA3v+XlNDsrYzNPRUwr8l7gw1r/nR8l4r96MDzSBDU8WEo8T6C06SvwaXR8SpzsjO3sq5KMP86wXWg7Rj4g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.0':
resolution: {integrity: sha512-SsrsFJsEYAJHO4N/r2P0aK6o8si6f1lprR+Ej8J731XJqTckSGs/HFHcbxOyW/iKt+LNUvZa59/VlJmjhF4bEQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.1':
resolution: {integrity: sha512-CccqDGL6ZrF3/EFWZefvKW7QwwRdxlHUO8NVBKNVcNq6womrPDvqB6xc9icACtE0XB0a7PLoSTkAg8bQVkTO2w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.0':
resolution: {integrity: sha512-wwJDpEGl6+sOygic8QKu0OHVB8SiodqF1fr5jvUlSFfS6tJss/E9vBc2aFjl7zI6KpAIYfIzIgM006lRrZtWCQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.1':
resolution: {integrity: sha512-DwXPk9GfuU/xG9tmCyXFVkCr6X3W8ZCoL5Ptb0pbltEx1/LCcg7T+PBqDlPiiinNCD6ilIoMJDWsnJ8ikzZA7Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.0':
resolution: {integrity: sha512-nmzYhamLDJ8K+v3zWck79IaKMc350xZnWsf/GeaXO6E3MewSzd3lYkTiMi7lEp3/UwDm9NHfPguoPm+mhlSWQQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.1':
resolution: {integrity: sha512-bi47Zigu3692SJwdBvo8y1dEwE6B61stCwCFnuRWJVTfiM84B+VTSCV661CSWJmIZzmcy7J5J3kWyxL02iHj0w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.972.0':
resolution: {integrity: sha512-6mYyfk1SrMZ15cH9T53yAF4YSnvq4yU1Xlgm3nqV1gZVQzmF5kr4t/F3BU3ygbvzi4uSwWxG3I3TYYS5eMlAyg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.972.1':
resolution: {integrity: sha512-dLZVNhM7wSgVUFsgVYgI5hb5Z/9PUkT46pk/SHrSmUqfx6YDvoV4YcPtaiRqviPpEGGiRtdQMEadyOKIRqulUQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.0':
resolution: {integrity: sha512-vsJXBGL8H54kz4T6do3p5elATj5d1izVGUXMluRJntm9/I0be/zUYtdd4oDTM2kSUmd4Zhyw3fMQ9lw7CVhd4A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.1':
resolution: {integrity: sha512-YMDeYgi0u687Ay0dAq/pFPKuijrlKTgsaB/UATbxCs/FzZfMiG4If5ksywHmmW7MiYUF8VVv+uou3TczvLrN4w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/eventstream-handler-node@3.972.0':
resolution: {integrity: sha512-B1AEv+TQOVxg2t60GMfrcagJvQjpx1p6UASUoFMLevV9K3WNI5qYTjtutMiifKY0HwK6g86zXgN/dpeaSi3q5Q==}
engines: {node: '>=20.0.0'}
@@ -545,18 +598,34 @@ packages:
resolution: {integrity: sha512-3eztFI6F9/eHtkIaWKN3nT+PM+eQ6p1MALDuNshFk323ixuCZzOOVT8oUqtZa30Z6dycNXJwhlIq7NhUVFfimw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-host-header@3.972.1':
resolution: {integrity: sha512-/R82lXLPmZ9JaUGSUdKtBp2k/5xQxvBT3zZWyKiBOhyulFotlfvdlrO8TnqstBimsl4lYEYySDL+W6ldFh6ALg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-logger@3.972.0':
resolution: {integrity: sha512-ZvdyVRwzK+ra31v1pQrgbqR/KsLD+wwJjHgko6JfoKUBIcEfAwJzQKO6HspHxdHWTVUz6MgvwskheR/TTYZl2g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-logger@3.972.1':
resolution: {integrity: sha512-JGgFl6cHg9G2FHu4lyFIzmFN8KESBiRr84gLC3Aeni0Gt1nKm+KxWLBuha/RPcXxJygGXCcMM4AykkIwxor8RA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-recursion-detection@3.972.0':
resolution: {integrity: sha512-F2SmUeO+S6l1h6dydNet3BQIk173uAkcfU1HDkw/bUdRLAnh15D3HP9vCZ7oCPBNcdEICbXYDmx0BR9rRUHGlQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-recursion-detection@3.972.1':
resolution: {integrity: sha512-taGzNRe8vPHjnliqXIHp9kBgIemLE/xCaRTMH1NH0cncHeaPcjxtnCroAAM9aOlPuKvBe2CpZESyvM1+D8oI7Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-user-agent@3.972.0':
resolution: {integrity: sha512-kFHQm2OCBJCzGWRafgdWHGFjitUXY/OxXngymcX4l8CiyiNDZB27HDDBg2yLj3OUJc4z4fexLMmP8r9vgag19g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-user-agent@3.972.2':
resolution: {integrity: sha512-d+Exq074wy0X6wvShg/kmZVtkah+28vMuqCtuY3cydg8LUZOJBtbAolCpEJizSyb8mJJZF9BjWaTANXL4OYnkg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-websocket@3.972.0':
resolution: {integrity: sha512-3pvbb/HtE7A8U38jk24RQ9T92d40NNSzjDEVEkBYZYhxExVcJ/Lk5Z+NM283FEtoi1T++oYrLuYDr1CIQxnaXQ==}
engines: {node: '>= 14.0.0'}
@@ -565,18 +634,42 @@ packages:
resolution: {integrity: sha512-QGlbnuGzSQJVG6bR9Qw6G0Blh6abFR4VxNa61ttMbzy9jt28xmk2iGtrYLrQPlCCPhY6enHqjTWm3n3LOb0wAw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.974.0':
resolution: {integrity: sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.975.0':
resolution: {integrity: sha512-OkeFHPlQj2c/Y5bQGkX14pxhDWUGUFt3LRHhjcDKsSCw6lrxKcxN3WFZN0qbJwKNydP+knL5nxvfgKiCLpTLRA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/region-config-resolver@3.972.0':
resolution: {integrity: sha512-JyOf+R/6vJW8OEVFCAyzEOn2reri/Q+L0z9zx4JQSKWvTmJ1qeFO25sOm8VIfB8URKhfGRTQF30pfYaH2zxt/A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/region-config-resolver@3.972.1':
resolution: {integrity: sha512-voIY8RORpxLAEgEkYaTFnkaIuRwVBEc+RjVZYcSSllPV+ZEKAacai6kNhJeE3D70Le+JCfvRb52tng/AVHY+jQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.972.0':
resolution: {integrity: sha512-kWlXG+y5nZhgXGEtb72Je+EvqepBPs8E3vZse//1PYLWs2speFqbGE/ywCXmzEJgHgVqSB/u/lqBvs5WlYmSqQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.974.0':
resolution: {integrity: sha512-cBykL0LiccKIgNhGWvQRTPvsBLPZxnmJU3pYxG538jpFX8lQtrCy1L7mmIHNEdxIdIGEPgAEHF8/JQxgBToqUQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.975.0':
resolution: {integrity: sha512-AWQt64hkVbDQ+CmM09wnvSk2mVyH4iRROkmYkr3/lmUtFNbE2L/fnw26sckZnUcFCsHPqbkQrcsZAnTcBLbH4w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.972.0':
resolution: {integrity: sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.973.0':
resolution: {integrity: sha512-jYIdB7a7jhRTvyb378nsjyvJh1Si+zVduJ6urMNGpz8RjkmHZ+9vM2H07XaIB2Cfq0GhJRZYOfUCH8uqQhqBkQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/util-endpoints@3.972.0':
resolution: {integrity: sha512-6JHsl1V/a1ZW8D8AFfd4R52fwZPnZ5H4U6DS8m/bWT8qad72NvbOFAC7U2cDtFs2TShqUO3TEiX/EJibtY3ijg==}
engines: {node: '>=20.0.0'}
@@ -592,6 +685,9 @@ packages:
'@aws-sdk/util-user-agent-browser@3.972.0':
resolution: {integrity: sha512-eOLdkQyoRbDgioTS3Orr7iVsVEutJyMZxvyZ6WAF95IrF0kfWx5Rd/KXnfbnG/VKa2CvjZiitWfouLzfVEyvJA==}
'@aws-sdk/util-user-agent-browser@3.972.1':
resolution: {integrity: sha512-IgF55NFmJX8d9Wql9M0nEpk2eYbuD8G4781FN4/fFgwTXBn86DvlZJuRWDCMcMqZymnBVX7HW9r+3r9ylqfW0w==}
'@aws-sdk/util-user-agent-node@3.972.0':
resolution: {integrity: sha512-GOy+AiSrE9kGiojiwlZvVVSXwylu4+fmP0MJfvras/MwP09RB/YtQuOVR1E0fKQc6OMwaTNBjgAbOEhxuWFbAw==}
engines: {node: '>=20.0.0'}
@@ -601,10 +697,23 @@ packages:
aws-crt:
optional: true
'@aws-sdk/util-user-agent-node@3.972.1':
resolution: {integrity: sha512-oIs4JFcADzoZ0c915R83XvK2HltWupxNsXUIuZse2rgk7b97zTpkxaqXiH0h9ylh31qtgo/t8hp4tIqcsMrEbQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
aws-crt: '>=1.0.0'
peerDependenciesMeta:
aws-crt:
optional: true
'@aws-sdk/xml-builder@3.972.0':
resolution: {integrity: sha512-POaGMcXnozzqBUyJM3HLUZ9GR6OKJWPGJEmhtTnxZXt8B6JcJ/6K3xRJ5H/j8oovVLz8Wg6vFxAHv8lvuASxMg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.1':
resolution: {integrity: sha512-6zZGlPOqn7Xb+25MAXGb1JhgvaC5HjZj6GzszuVrnEgbhvzBRFGKYemuHBV4bho+dtqeYKPgaZUv7/e80hIGNg==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.2.3':
resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==}
engines: {node: '>=18.0.0'}
@@ -2221,6 +2330,10 @@ packages:
resolution: {integrity: sha512-bg2TfzgsERyETAxc/Ims/eJX8eAnIeTi4r4LHpMpfF/2NyO6RsWis0rjKcCPaGksljmOb23BZRiCeT/3NvwkXw==}
engines: {node: '>=18.0.0'}
'@smithy/core@3.21.1':
resolution: {integrity: sha512-NUH8R4O6FkN8HKMojzbGg/5pNjsfTjlMmeFclyPfPaXXUrbr5TzhWgbf7t92wfrpCHRgpjyz7ffASIS3wX28aA==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.2.8':
resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==}
engines: {node: '>=18.0.0'}
@@ -2273,10 +2386,18 @@ packages:
resolution: {integrity: sha512-kwWpNltpxrvPabnjEFvwSmA+66l6s2ReCvgVSzW/z92LU4T28fTdgZ18IdYRYOrisu2NMQ0jUndRScbO65A/zg==}
engines: {node: '>=18.0.0'}
'@smithy/middleware-endpoint@4.4.11':
resolution: {integrity: sha512-/WqsrycweGGfb9sSzME4CrsuayjJF6BueBmkKlcbeU5q18OhxRrvvKlmfw3tpDsK5ilx2XUJvoukwxHB0nHs/Q==}
engines: {node: '>=18.0.0'}
'@smithy/middleware-retry@4.4.26':
resolution: {integrity: sha512-ozZMoTAr+B2aVYfLYfkssFvc8ZV3p/vLpVQ7/k277xxUOA9ykSPe5obL2j6yHfbdrM/SZV7qj0uk/hSqavHrLw==}
engines: {node: '>=18.0.0'}
'@smithy/middleware-retry@4.4.27':
resolution: {integrity: sha512-xFUYCGRVsfgiN5EjsJJSzih9+yjStgMTCLANPlf0LVQkPDYCe0hz97qbdTZosFOiYlGBlHYityGRxrQ/hxhfVQ==}
engines: {node: '>=18.0.0'}
'@smithy/middleware-serde@4.2.9':
resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==}
engines: {node: '>=18.0.0'}
@@ -2325,6 +2446,10 @@ packages:
resolution: {integrity: sha512-6o804SCyHGMXAb5mFJ+iTy9kVKv7F91a9szN0J+9X6p8A0NrdpUxdaC57aye2ipQkP2C4IAqETEpGZ0Zj77Haw==}
engines: {node: '>=18.0.0'}
'@smithy/smithy-client@4.10.12':
resolution: {integrity: sha512-VKO/HKoQ5OrSHW6AJUmEnUKeXI1/5LfCwO9cwyao7CmLvGnZeM1i36Lyful3LK1XU7HwTVieTqO1y2C/6t3qtA==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.12.0':
resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==}
engines: {node: '>=18.0.0'}
@@ -2361,10 +2486,18 @@ packages:
resolution: {integrity: sha512-8ugoNMtss2dJHsXnqsibGPqoaafvWJPACmYKxJ4E6QWaDrixsAemmiMMAVbvwYadjR0H9G2+AlzsInSzRi8PSw==}
engines: {node: '>=18.0.0'}
'@smithy/util-defaults-mode-browser@4.3.26':
resolution: {integrity: sha512-vva0dzYUTgn7DdE0uaha10uEdAgmdLnNFowKFjpMm6p2R0XDk5FHPX3CBJLzWQkQXuEprsb0hGz9YwbicNWhjw==}
engines: {node: '>=18.0.0'}
'@smithy/util-defaults-mode-node@4.2.28':
resolution: {integrity: sha512-mjUdcP8h3E0K/XvNMi9oBXRV3DMCzeRiYIieZ1LQ7jq5tu6GH/GTWym7a1xIIE0pKSoLcpGsaImuQhGPSIJzAA==}
engines: {node: '>=18.0.0'}
'@smithy/util-defaults-mode-node@4.2.29':
resolution: {integrity: sha512-c6D7IUBsZt/aNnTBHMTf+OVh+h/JcxUUgfTcIJaWRe6zhOum1X+pNKSZtZ+7fbOn5I99XVFtmrnXKv8yHHErTQ==}
engines: {node: '>=18.0.0'}
'@smithy/util-endpoints@3.2.8':
resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==}
engines: {node: '>=18.0.0'}
@@ -5331,7 +5464,7 @@ snapshots:
'@aws-crypto/sha256-js': 5.2.0
'@aws-crypto/supports-web-crypto': 5.2.0
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.972.0
'@aws-sdk/types': 3.973.0
'@aws-sdk/util-locate-window': 3.965.3
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
@@ -5339,7 +5472,7 @@ snapshots:
'@aws-crypto/sha256-js@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.972.0
'@aws-sdk/types': 3.973.0
tslib: 2.8.1
'@aws-crypto/supports-web-crypto@5.2.0':
@@ -5404,6 +5537,51 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/client-bedrock@3.975.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.973.1
'@aws-sdk/credential-provider-node': 3.972.1
'@aws-sdk/middleware-host-header': 3.972.1
'@aws-sdk/middleware-logger': 3.972.1
'@aws-sdk/middleware-recursion-detection': 3.972.1
'@aws-sdk/middleware-user-agent': 3.972.2
'@aws-sdk/region-config-resolver': 3.972.1
'@aws-sdk/token-providers': 3.975.0
'@aws-sdk/types': 3.973.0
'@aws-sdk/util-endpoints': 3.972.0
'@aws-sdk/util-user-agent-browser': 3.972.1
'@aws-sdk/util-user-agent-node': 3.972.1
'@smithy/config-resolver': 4.4.6
'@smithy/core': 3.21.1
'@smithy/fetch-http-handler': 5.3.9
'@smithy/hash-node': 4.2.8
'@smithy/invalid-dependency': 4.2.8
'@smithy/middleware-content-length': 4.2.8
'@smithy/middleware-endpoint': 4.4.11
'@smithy/middleware-retry': 4.4.27
'@smithy/middleware-serde': 4.2.9
'@smithy/middleware-stack': 4.2.8
'@smithy/node-config-provider': 4.3.8
'@smithy/node-http-handler': 4.4.8
'@smithy/protocol-http': 5.3.8
'@smithy/smithy-client': 4.10.12
'@smithy/types': 4.12.0
'@smithy/url-parser': 4.2.8
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
'@smithy/util-body-length-node': 4.2.1
'@smithy/util-defaults-mode-browser': 4.3.26
'@smithy/util-defaults-mode-node': 4.2.29
'@smithy/util-endpoints': 3.2.8
'@smithy/util-middleware': 4.2.8
'@smithy/util-retry': 4.2.8
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/client-sso@3.972.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
@@ -5447,6 +5625,49 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/client-sso@3.974.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.973.1
'@aws-sdk/middleware-host-header': 3.972.1
'@aws-sdk/middleware-logger': 3.972.1
'@aws-sdk/middleware-recursion-detection': 3.972.1
'@aws-sdk/middleware-user-agent': 3.972.2
'@aws-sdk/region-config-resolver': 3.972.1
'@aws-sdk/types': 3.973.0
'@aws-sdk/util-endpoints': 3.972.0
'@aws-sdk/util-user-agent-browser': 3.972.1
'@aws-sdk/util-user-agent-node': 3.972.1
'@smithy/config-resolver': 4.4.6
'@smithy/core': 3.21.1
'@smithy/fetch-http-handler': 5.3.9
'@smithy/hash-node': 4.2.8
'@smithy/invalid-dependency': 4.2.8
'@smithy/middleware-content-length': 4.2.8
'@smithy/middleware-endpoint': 4.4.11
'@smithy/middleware-retry': 4.4.27
'@smithy/middleware-serde': 4.2.9
'@smithy/middleware-stack': 4.2.8
'@smithy/node-config-provider': 4.3.8
'@smithy/node-http-handler': 4.4.8
'@smithy/protocol-http': 5.3.8
'@smithy/smithy-client': 4.10.12
'@smithy/types': 4.12.0
'@smithy/url-parser': 4.2.8
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
'@smithy/util-body-length-node': 4.2.1
'@smithy/util-defaults-mode-browser': 4.3.26
'@smithy/util-defaults-mode-node': 4.2.29
'@smithy/util-endpoints': 3.2.8
'@smithy/util-middleware': 4.2.8
'@smithy/util-retry': 4.2.8
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/core@3.972.0':
dependencies:
'@aws-sdk/types': 3.972.0
@@ -5463,6 +5684,22 @@ snapshots:
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
'@aws-sdk/core@3.973.1':
dependencies:
'@aws-sdk/types': 3.973.0
'@aws-sdk/xml-builder': 3.972.1
'@smithy/core': 3.21.1
'@smithy/node-config-provider': 4.3.8
'@smithy/property-provider': 4.2.8
'@smithy/protocol-http': 5.3.8
'@smithy/signature-v4': 5.3.8
'@smithy/smithy-client': 4.10.12
'@smithy/types': 4.12.0
'@smithy/util-base64': 4.3.0
'@smithy/util-middleware': 4.2.8
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.0':
dependencies:
'@aws-sdk/core': 3.972.0
@@ -5471,6 +5708,14 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.1':
dependencies:
'@aws-sdk/core': 3.973.1
'@aws-sdk/types': 3.973.0
'@smithy/property-provider': 4.2.8
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.0':
dependencies:
'@aws-sdk/core': 3.972.0
@@ -5484,6 +5729,19 @@ snapshots:
'@smithy/util-stream': 4.5.10
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.2':
dependencies:
'@aws-sdk/core': 3.973.1
'@aws-sdk/types': 3.973.0
'@smithy/fetch-http-handler': 5.3.9
'@smithy/node-http-handler': 4.4.8
'@smithy/property-provider': 4.2.8
'@smithy/protocol-http': 5.3.8
'@smithy/smithy-client': 4.10.12
'@smithy/types': 4.12.0
'@smithy/util-stream': 4.5.10
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.972.0':
dependencies:
'@aws-sdk/core': 3.972.0
@@ -5503,6 +5761,25 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-ini@3.972.1':
dependencies:
'@aws-sdk/core': 3.973.1
'@aws-sdk/credential-provider-env': 3.972.1
'@aws-sdk/credential-provider-http': 3.972.2
'@aws-sdk/credential-provider-login': 3.972.1
'@aws-sdk/credential-provider-process': 3.972.1
'@aws-sdk/credential-provider-sso': 3.972.1
'@aws-sdk/credential-provider-web-identity': 3.972.1
'@aws-sdk/nested-clients': 3.974.0
'@aws-sdk/types': 3.973.0
'@smithy/credential-provider-imds': 4.2.8
'@smithy/property-provider': 4.2.8
'@smithy/shared-ini-file-loader': 4.4.3
'@smithy/types': 4.12.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-login@3.972.0':
dependencies:
'@aws-sdk/core': 3.972.0
@@ -5516,6 +5793,19 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-login@3.972.1':
dependencies:
'@aws-sdk/core': 3.973.1
'@aws-sdk/nested-clients': 3.974.0
'@aws-sdk/types': 3.973.0
'@smithy/property-provider': 4.2.8
'@smithy/protocol-http': 5.3.8
'@smithy/shared-ini-file-loader': 4.4.3
'@smithy/types': 4.12.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-node@3.972.0':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.0
@@ -5533,6 +5823,23 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-node@3.972.1':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.1
'@aws-sdk/credential-provider-http': 3.972.2
'@aws-sdk/credential-provider-ini': 3.972.1
'@aws-sdk/credential-provider-process': 3.972.1
'@aws-sdk/credential-provider-sso': 3.972.1
'@aws-sdk/credential-provider-web-identity': 3.972.1
'@aws-sdk/types': 3.973.0
'@smithy/credential-provider-imds': 4.2.8
'@smithy/property-provider': 4.2.8
'@smithy/shared-ini-file-loader': 4.4.3
'@smithy/types': 4.12.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-process@3.972.0':
dependencies:
'@aws-sdk/core': 3.972.0
@@ -5542,6 +5849,15 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.1':
dependencies:
'@aws-sdk/core': 3.973.1
'@aws-sdk/types': 3.973.0
'@smithy/property-provider': 4.2.8
'@smithy/shared-ini-file-loader': 4.4.3
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.972.0':
dependencies:
'@aws-sdk/client-sso': 3.972.0
@@ -5555,6 +5871,19 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-sso@3.972.1':
dependencies:
'@aws-sdk/client-sso': 3.974.0
'@aws-sdk/core': 3.973.1
'@aws-sdk/token-providers': 3.974.0
'@aws-sdk/types': 3.973.0
'@smithy/property-provider': 4.2.8
'@smithy/shared-ini-file-loader': 4.4.3
'@smithy/types': 4.12.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-web-identity@3.972.0':
dependencies:
'@aws-sdk/core': 3.972.0
@@ -5567,6 +5896,18 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-web-identity@3.972.1':
dependencies:
'@aws-sdk/core': 3.973.1
'@aws-sdk/nested-clients': 3.974.0
'@aws-sdk/types': 3.973.0
'@smithy/property-provider': 4.2.8
'@smithy/shared-ini-file-loader': 4.4.3
'@smithy/types': 4.12.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/eventstream-handler-node@3.972.0':
dependencies:
'@aws-sdk/types': 3.972.0
@@ -5588,12 +5929,25 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/middleware-host-header@3.972.1':
dependencies:
'@aws-sdk/types': 3.973.0
'@smithy/protocol-http': 5.3.8
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/middleware-logger@3.972.0':
dependencies:
'@aws-sdk/types': 3.972.0
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/middleware-logger@3.972.1':
dependencies:
'@aws-sdk/types': 3.973.0
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/middleware-recursion-detection@3.972.0':
dependencies:
'@aws-sdk/types': 3.972.0
@@ -5602,6 +5956,14 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/middleware-recursion-detection@3.972.1':
dependencies:
'@aws-sdk/types': 3.973.0
'@aws/lambda-invoke-store': 0.2.3
'@smithy/protocol-http': 5.3.8
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/middleware-user-agent@3.972.0':
dependencies:
'@aws-sdk/core': 3.972.0
@@ -5612,6 +5974,16 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/middleware-user-agent@3.972.2':
dependencies:
'@aws-sdk/core': 3.973.1
'@aws-sdk/types': 3.973.0
'@aws-sdk/util-endpoints': 3.972.0
'@smithy/core': 3.21.1
'@smithy/protocol-http': 5.3.8
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/middleware-websocket@3.972.0':
dependencies:
'@aws-sdk/types': 3.972.0
@@ -5668,6 +6040,92 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/nested-clients@3.974.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.973.1
'@aws-sdk/middleware-host-header': 3.972.1
'@aws-sdk/middleware-logger': 3.972.1
'@aws-sdk/middleware-recursion-detection': 3.972.1
'@aws-sdk/middleware-user-agent': 3.972.2
'@aws-sdk/region-config-resolver': 3.972.1
'@aws-sdk/types': 3.973.0
'@aws-sdk/util-endpoints': 3.972.0
'@aws-sdk/util-user-agent-browser': 3.972.1
'@aws-sdk/util-user-agent-node': 3.972.1
'@smithy/config-resolver': 4.4.6
'@smithy/core': 3.21.1
'@smithy/fetch-http-handler': 5.3.9
'@smithy/hash-node': 4.2.8
'@smithy/invalid-dependency': 4.2.8
'@smithy/middleware-content-length': 4.2.8
'@smithy/middleware-endpoint': 4.4.11
'@smithy/middleware-retry': 4.4.27
'@smithy/middleware-serde': 4.2.9
'@smithy/middleware-stack': 4.2.8
'@smithy/node-config-provider': 4.3.8
'@smithy/node-http-handler': 4.4.8
'@smithy/protocol-http': 5.3.8
'@smithy/smithy-client': 4.10.12
'@smithy/types': 4.12.0
'@smithy/url-parser': 4.2.8
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
'@smithy/util-body-length-node': 4.2.1
'@smithy/util-defaults-mode-browser': 4.3.26
'@smithy/util-defaults-mode-node': 4.2.29
'@smithy/util-endpoints': 3.2.8
'@smithy/util-middleware': 4.2.8
'@smithy/util-retry': 4.2.8
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/nested-clients@3.975.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.973.1
'@aws-sdk/middleware-host-header': 3.972.1
'@aws-sdk/middleware-logger': 3.972.1
'@aws-sdk/middleware-recursion-detection': 3.972.1
'@aws-sdk/middleware-user-agent': 3.972.2
'@aws-sdk/region-config-resolver': 3.972.1
'@aws-sdk/types': 3.973.0
'@aws-sdk/util-endpoints': 3.972.0
'@aws-sdk/util-user-agent-browser': 3.972.1
'@aws-sdk/util-user-agent-node': 3.972.1
'@smithy/config-resolver': 4.4.6
'@smithy/core': 3.21.1
'@smithy/fetch-http-handler': 5.3.9
'@smithy/hash-node': 4.2.8
'@smithy/invalid-dependency': 4.2.8
'@smithy/middleware-content-length': 4.2.8
'@smithy/middleware-endpoint': 4.4.11
'@smithy/middleware-retry': 4.4.27
'@smithy/middleware-serde': 4.2.9
'@smithy/middleware-stack': 4.2.8
'@smithy/node-config-provider': 4.3.8
'@smithy/node-http-handler': 4.4.8
'@smithy/protocol-http': 5.3.8
'@smithy/smithy-client': 4.10.12
'@smithy/types': 4.12.0
'@smithy/url-parser': 4.2.8
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
'@smithy/util-body-length-node': 4.2.1
'@smithy/util-defaults-mode-browser': 4.3.26
'@smithy/util-defaults-mode-node': 4.2.29
'@smithy/util-endpoints': 3.2.8
'@smithy/util-middleware': 4.2.8
'@smithy/util-retry': 4.2.8
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/region-config-resolver@3.972.0':
dependencies:
'@aws-sdk/types': 3.972.0
@@ -5676,6 +6134,14 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/region-config-resolver@3.972.1':
dependencies:
'@aws-sdk/types': 3.973.0
'@smithy/config-resolver': 4.4.6
'@smithy/node-config-provider': 4.3.8
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.972.0':
dependencies:
'@aws-sdk/core': 3.972.0
@@ -5688,11 +6154,40 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@aws-sdk/token-providers@3.974.0':
dependencies:
'@aws-sdk/core': 3.973.1
'@aws-sdk/nested-clients': 3.974.0
'@aws-sdk/types': 3.973.0
'@smithy/property-provider': 4.2.8
'@smithy/shared-ini-file-loader': 4.4.3
'@smithy/types': 4.12.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/token-providers@3.975.0':
dependencies:
'@aws-sdk/core': 3.973.1
'@aws-sdk/nested-clients': 3.975.0
'@aws-sdk/types': 3.973.0
'@smithy/property-provider': 4.2.8
'@smithy/shared-ini-file-loader': 4.4.3
'@smithy/types': 4.12.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/types@3.972.0':
dependencies:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/types@3.973.0':
dependencies:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/util-endpoints@3.972.0':
dependencies:
'@aws-sdk/types': 3.972.0
@@ -5719,6 +6214,13 @@ snapshots:
bowser: 2.13.1
tslib: 2.8.1
'@aws-sdk/util-user-agent-browser@3.972.1':
dependencies:
'@aws-sdk/types': 3.973.0
'@smithy/types': 4.12.0
bowser: 2.13.1
tslib: 2.8.1
'@aws-sdk/util-user-agent-node@3.972.0':
dependencies:
'@aws-sdk/middleware-user-agent': 3.972.0
@@ -5727,12 +6229,26 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/util-user-agent-node@3.972.1':
dependencies:
'@aws-sdk/middleware-user-agent': 3.972.2
'@aws-sdk/types': 3.973.0
'@smithy/node-config-provider': 4.3.8
'@smithy/types': 4.12.0
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.0':
dependencies:
'@smithy/types': 4.12.0
fast-xml-parser: 5.2.5
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.1':
dependencies:
'@smithy/types': 4.12.0
fast-xml-parser: 5.2.5
tslib: 2.8.1
'@aws/lambda-invoke-store@0.2.3': {}
'@azure/abort-controller@2.1.2':
@@ -7302,6 +7818,19 @@ snapshots:
'@smithy/uuid': 1.1.0
tslib: 2.8.1
'@smithy/core@3.21.1':
dependencies:
'@smithy/middleware-serde': 4.2.9
'@smithy/protocol-http': 5.3.8
'@smithy/types': 4.12.0
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
'@smithy/util-middleware': 4.2.8
'@smithy/util-stream': 4.5.10
'@smithy/util-utf8': 4.2.0
'@smithy/uuid': 1.1.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.2.8':
dependencies:
'@smithy/node-config-provider': 4.3.8
@@ -7385,6 +7914,17 @@ snapshots:
'@smithy/util-middleware': 4.2.8
tslib: 2.8.1
'@smithy/middleware-endpoint@4.4.11':
dependencies:
'@smithy/core': 3.21.1
'@smithy/middleware-serde': 4.2.9
'@smithy/node-config-provider': 4.3.8
'@smithy/shared-ini-file-loader': 4.4.3
'@smithy/types': 4.12.0
'@smithy/url-parser': 4.2.8
'@smithy/util-middleware': 4.2.8
tslib: 2.8.1
'@smithy/middleware-retry@4.4.26':
dependencies:
'@smithy/node-config-provider': 4.3.8
@@ -7397,6 +7937,18 @@ snapshots:
'@smithy/uuid': 1.1.0
tslib: 2.8.1
'@smithy/middleware-retry@4.4.27':
dependencies:
'@smithy/node-config-provider': 4.3.8
'@smithy/protocol-http': 5.3.8
'@smithy/service-error-classification': 4.2.8
'@smithy/smithy-client': 4.10.12
'@smithy/types': 4.12.0
'@smithy/util-middleware': 4.2.8
'@smithy/util-retry': 4.2.8
'@smithy/uuid': 1.1.0
tslib: 2.8.1
'@smithy/middleware-serde@4.2.9':
dependencies:
'@smithy/protocol-http': 5.3.8
@@ -7474,6 +8026,16 @@ snapshots:
'@smithy/util-stream': 4.5.10
tslib: 2.8.1
'@smithy/smithy-client@4.10.12':
dependencies:
'@smithy/core': 3.21.1
'@smithy/middleware-endpoint': 4.4.11
'@smithy/middleware-stack': 4.2.8
'@smithy/protocol-http': 5.3.8
'@smithy/types': 4.12.0
'@smithy/util-stream': 4.5.10
tslib: 2.8.1
'@smithy/types@4.12.0':
dependencies:
tslib: 2.8.1
@@ -7519,6 +8081,13 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/util-defaults-mode-browser@4.3.26':
dependencies:
'@smithy/property-provider': 4.2.8
'@smithy/smithy-client': 4.10.12
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/util-defaults-mode-node@4.2.28':
dependencies:
'@smithy/config-resolver': 4.4.6
@@ -7529,6 +8098,16 @@ snapshots:
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/util-defaults-mode-node@4.2.29':
dependencies:
'@smithy/config-resolver': 4.4.6
'@smithy/credential-provider-imds': 4.2.8
'@smithy/node-config-provider': 4.3.8
'@smithy/property-provider': 4.2.8
'@smithy/smithy-client': 4.10.12
'@smithy/types': 4.12.0
tslib: 2.8.1
'@smithy/util-endpoints@3.2.8':
dependencies:
'@smithy/node-config-provider': 4.3.8

View File

@@ -0,0 +1,193 @@
import type { BedrockClient } from "@aws-sdk/client-bedrock";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMock = vi.fn();
const clientFactory = () => ({ send: sendMock }) as unknown as BedrockClient;
describe("bedrock discovery", () => {
beforeEach(() => {
sendMock.mockReset();
});
it("filters to active streaming text models and maps modalities", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT", "IMAGE"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
{
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
modelName: "Claude 3 Haiku",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: false,
modelLifecycle: { status: "ACTIVE" },
},
{
modelId: "meta.llama3-8b-instruct-v1:0",
modelName: "Llama 3 8B",
providerName: "meta",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "INACTIVE" },
},
{
modelId: "amazon.titan-embed-text-v1",
modelName: "Titan Embed",
providerName: "amazon",
inputModalities: ["TEXT"],
outputModalities: ["EMBEDDING"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet",
reasoning: false,
input: ["text", "image"],
contextWindow: 32000,
maxTokens: 4096,
});
});
it("applies provider filter", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const models = await discoverBedrockModels({
region: "us-east-1",
config: { providerFilter: ["amazon"] },
clientFactory,
});
expect(models).toHaveLength(0);
});
it("uses configured defaults for context and max tokens", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const models = await discoverBedrockModels({
region: "us-east-1",
config: { defaultContextWindow: 64000, defaultMaxTokens: 8192 },
clientFactory,
});
expect(models[0]).toMatchObject({ contextWindow: 64000, maxTokens: 8192 });
});
it("caches results when refreshInterval is enabled", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
await discoverBedrockModels({ region: "us-east-1", clientFactory });
await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(sendMock).toHaveBeenCalledTimes(1);
});
it("skips cache when refreshInterval is 0", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 0 },
clientFactory,
});
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 0 },
clientFactory,
});
expect(sendMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,200 @@
import {
BedrockClient,
ListFoundationModelsCommand,
type ListFoundationModelsCommandOutput,
} from "@aws-sdk/client-bedrock";
import type { BedrockDiscoveryConfig, ModelDefinitionConfig } from "../config/types.js";
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600;
const DEFAULT_CONTEXT_WINDOW = 32000;
const DEFAULT_MAX_TOKENS = 4096;
const DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
type BedrockModelSummary = NonNullable<ListFoundationModelsCommandOutput["modelSummaries"]>[number];
type BedrockDiscoveryCacheEntry = {
expiresAt: number;
value?: ModelDefinitionConfig[];
inFlight?: Promise<ModelDefinitionConfig[]>;
};
const discoveryCache = new Map<string, BedrockDiscoveryCacheEntry>();
let hasLoggedBedrockError = false;
function normalizeProviderFilter(filter?: string[]): string[] {
if (!filter || filter.length === 0) return [];
const normalized = new Set(
filter.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0),
);
return Array.from(normalized).sort();
}
function buildCacheKey(params: {
region: string;
providerFilter: string[];
refreshIntervalSeconds: number;
defaultContextWindow: number;
defaultMaxTokens: number;
}): string {
return JSON.stringify(params);
}
function includesTextModalities(modalities?: Array<string>): boolean {
return (modalities ?? []).some((entry) => entry.toLowerCase() === "text");
}
function isActive(summary: BedrockModelSummary): boolean {
const status = summary.modelLifecycle?.status;
return typeof status === "string" ? status.toUpperCase() === "ACTIVE" : false;
}
function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image"> {
const inputs = summary.inputModalities ?? [];
const mapped = new Set<"text" | "image">();
for (const modality of inputs) {
const lower = modality.toLowerCase();
if (lower === "text") mapped.add("text");
if (lower === "image") mapped.add("image");
}
if (mapped.size === 0) mapped.add("text");
return Array.from(mapped);
}
function inferReasoningSupport(summary: BedrockModelSummary): boolean {
const haystack = `${summary.modelId ?? ""} ${summary.modelName ?? ""}`.toLowerCase();
return haystack.includes("reasoning") || haystack.includes("thinking");
}
function resolveDefaultContextWindow(config?: BedrockDiscoveryConfig): number {
const value = Math.floor(config?.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW);
return value > 0 ? value : DEFAULT_CONTEXT_WINDOW;
}
function resolveDefaultMaxTokens(config?: BedrockDiscoveryConfig): number {
const value = Math.floor(config?.defaultMaxTokens ?? DEFAULT_MAX_TOKENS);
return value > 0 ? value : DEFAULT_MAX_TOKENS;
}
function matchesProviderFilter(summary: BedrockModelSummary, filter: string[]): boolean {
if (filter.length === 0) return true;
const providerName =
summary.providerName ??
(typeof summary.modelId === "string" ? summary.modelId.split(".")[0] : undefined);
const normalized = providerName?.trim().toLowerCase();
if (!normalized) return false;
return filter.includes(normalized);
}
function shouldIncludeSummary(summary: BedrockModelSummary, filter: string[]): boolean {
if (!summary.modelId?.trim()) return false;
if (!matchesProviderFilter(summary, filter)) return false;
if (summary.responseStreamingSupported !== true) return false;
if (!includesTextModalities(summary.outputModalities)) return false;
if (!isActive(summary)) return false;
return true;
}
function toModelDefinition(
summary: BedrockModelSummary,
defaults: { contextWindow: number; maxTokens: number },
): ModelDefinitionConfig {
const id = summary.modelId?.trim() ?? "";
return {
id,
name: summary.modelName?.trim() || id,
reasoning: inferReasoningSupport(summary),
input: mapInputModalities(summary),
cost: DEFAULT_COST,
contextWindow: defaults.contextWindow,
maxTokens: defaults.maxTokens,
};
}
export function resetBedrockDiscoveryCacheForTest(): void {
discoveryCache.clear();
hasLoggedBedrockError = false;
}
export async function discoverBedrockModels(params: {
region: string;
config?: BedrockDiscoveryConfig;
now?: () => number;
clientFactory?: (region: string) => BedrockClient;
}): Promise<ModelDefinitionConfig[]> {
const refreshIntervalSeconds = Math.max(
0,
Math.floor(params.config?.refreshInterval ?? DEFAULT_REFRESH_INTERVAL_SECONDS),
);
const providerFilter = normalizeProviderFilter(params.config?.providerFilter);
const defaultContextWindow = resolveDefaultContextWindow(params.config);
const defaultMaxTokens = resolveDefaultMaxTokens(params.config);
const cacheKey = buildCacheKey({
region: params.region,
providerFilter,
refreshIntervalSeconds,
defaultContextWindow,
defaultMaxTokens,
});
const now = params.now?.() ?? Date.now();
if (refreshIntervalSeconds > 0) {
const cached = discoveryCache.get(cacheKey);
if (cached?.value && cached.expiresAt > now) {
return cached.value;
}
if (cached?.inFlight) {
return cached.inFlight;
}
}
const clientFactory = params.clientFactory ?? ((region: string) => new BedrockClient({ region }));
const client = clientFactory(params.region);
const discoveryPromise = (async () => {
const response = await client.send(new ListFoundationModelsCommand({}));
const discovered: ModelDefinitionConfig[] = [];
for (const summary of response.modelSummaries ?? []) {
if (!shouldIncludeSummary(summary, providerFilter)) continue;
discovered.push(
toModelDefinition(summary, {
contextWindow: defaultContextWindow,
maxTokens: defaultMaxTokens,
}),
);
}
return discovered.sort((a, b) => a.name.localeCompare(b.name));
})();
if (refreshIntervalSeconds > 0) {
discoveryCache.set(cacheKey, {
expiresAt: now + refreshIntervalSeconds * 1000,
inFlight: discoveryPromise,
});
}
try {
const value = await discoveryPromise;
if (refreshIntervalSeconds > 0) {
discoveryCache.set(cacheKey, {
expiresAt: now + refreshIntervalSeconds * 1000,
value,
});
}
return value;
} catch (error) {
if (refreshIntervalSeconds > 0) {
discoveryCache.delete(cacheKey);
}
if (!hasLoggedBedrockError) {
hasLoggedBedrockError = true;
console.warn(`[bedrock-discovery] Failed to list models: ${String(error)}`);
}
return [];
}
}

View File

@@ -75,12 +75,12 @@ function resolveEnvSourceLabel(params: {
return `${prefix}${params.label}`;
}
export function resolveAwsSdkEnvVarName(): string | undefined {
if (process.env[AWS_BEARER_ENV]?.trim()) return AWS_BEARER_ENV;
if (process.env[AWS_ACCESS_KEY_ENV]?.trim() && process.env[AWS_SECRET_KEY_ENV]?.trim()) {
export function resolveAwsSdkEnvVarName(env: NodeJS.ProcessEnv = process.env): string | undefined {
if (env[AWS_BEARER_ENV]?.trim()) return AWS_BEARER_ENV;
if (env[AWS_ACCESS_KEY_ENV]?.trim() && env[AWS_SECRET_KEY_ENV]?.trim()) {
return AWS_ACCESS_KEY_ENV;
}
if (process.env[AWS_PROFILE_ENV]?.trim()) return AWS_PROFILE_ENV;
if (env[AWS_PROFILE_ENV]?.trim()) return AWS_PROFILE_ENV;
return undefined;
}

View File

@@ -5,6 +5,7 @@ import {
} from "../providers/github-copilot-token.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
import {
buildSyntheticModelDefinition,
SYNTHETIC_BASE_URL,
@@ -375,3 +376,27 @@ export async function resolveImplicitCopilotProvider(params: {
models: [],
} satisfies ProviderConfig;
}
export async function resolveImplicitBedrockProvider(params: {
agentDir: string;
config?: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
}): Promise<ProviderConfig | null> {
const env = params.env ?? process.env;
const discoveryConfig = params.config?.models?.bedrockDiscovery;
const enabled = discoveryConfig?.enabled;
const hasAwsCreds = resolveAwsSdkEnvVarName(env) !== undefined;
if (enabled === false) return null;
if (enabled !== true && !hasAwsCreds) return null;
const region = discoveryConfig?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
const models = await discoverBedrockModels({ region, config: discoveryConfig });
if (models.length === 0) return null;
return {
baseUrl: `https://bedrock-runtime.${region}.amazonaws.com`,
api: "bedrock-converse-stream",
auth: "aws-sdk",
models,
} satisfies ProviderConfig;
}

View File

@@ -6,6 +6,7 @@ import { resolveClawdbotAgentDir } from "./agent-paths.js";
import {
normalizeProviders,
type ProviderConfig,
resolveImplicitBedrockProvider,
resolveImplicitCopilotProvider,
resolveImplicitProviders,
} from "./models-config.providers.js";
@@ -84,6 +85,13 @@ export async function ensureClawdbotModelsJson(
implicit: implicitProviders,
explicit: explicitProviders,
});
const implicitBedrock = await resolveImplicitBedrockProvider({ agentDir, config: cfg });
if (implicitBedrock) {
const existing = providers["amazon-bedrock"];
providers["amazon-bedrock"] = existing
? mergeProviderModels(implicitBedrock, existing)
: implicitBedrock;
}
const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir });
if (implicitCopilot && !providers["github-copilot"]) {
providers["github-copilot"] = implicitCopilot;

View File

@@ -165,6 +165,7 @@ const readSessionMessages = async (sessionFile: string) => {
};
describe("runEmbeddedPiAgent", () => {
const itIfNotWin32 = process.platform === "win32" ? it.skip : it;
it("writes models.json into the provided agentDir", async () => {
const sessionFile = nextSessionFile();
@@ -210,35 +211,39 @@ describe("runEmbeddedPiAgent", () => {
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
});
it("persists the first user message before assistant output", { timeout: 60_000 }, async () => {
const sessionFile = nextSessionFile();
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg);
itIfNotWin32(
"persists the first user message before assistant output",
{ timeout: 60_000 },
async () => {
const sessionFile = nextSessionFile();
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
const messages = await readSessionMessages(sessionFile);
const firstUserIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
);
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
if (firstAssistantIndex !== -1) {
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
}
});
const messages = await readSessionMessages(sessionFile);
const firstUserIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
);
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
if (firstAssistantIndex !== -1) {
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
}
},
);
it("persists the user message when prompt fails before assistant output", async () => {
const sessionFile = nextSessionFile();

View File

@@ -71,6 +71,8 @@ export async function runEmbeddedPiAgent(
const globalLane = resolveGlobalLane(params.lane);
const enqueueGlobal =
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
const enqueueSession =
params.enqueue ?? ((task, opts) => enqueueCommandInLane(sessionLane, task, opts));
const channelHint = params.messageChannel ?? params.messageProvider;
const resolvedToolResultFormat =
params.toolResultFormat ??
@@ -81,7 +83,7 @@ export async function runEmbeddedPiAgent(
: "markdown");
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
return enqueueCommandInLane(sessionLane, () =>
return enqueueSession(() =>
enqueueGlobal(async () => {
const started = Date.now();
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
@@ -273,6 +275,7 @@ export async function runEmbeddedPiAgent(
skillsSnapshot: params.skillsSnapshot,
prompt,
images: params.images,
disableTools: params.disableTools,
provider,
modelId,
model,

View File

@@ -196,30 +196,32 @@ export async function runEmbeddedAttempt(
// Check if the model supports native image input
const modelHasVision = params.model.input?.includes("image") ?? false;
const toolsRaw = createClawdbotCodingTools({
exec: {
...params.execOverrides,
elevated: params.bashElevated,
},
sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: params.model.provider,
modelId: params.modelId,
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
modelHasVision,
});
const toolsRaw = params.disableTools
? []
: createClawdbotCodingTools({
exec: {
...params.execOverrides,
elevated: params.bashElevated,
},
sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: params.model.provider,
modelId: params.modelId,
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
modelHasVision,
});
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider: params.provider });
logToolSchemasForGoogle({ tools, provider: params.provider });

View File

@@ -44,6 +44,8 @@ export type RunEmbeddedPiAgentParams = {
images?: ImageContent[];
/** Optional client-provided tools (OpenResponses hosted tools). */
clientTools?: ClientToolDefinition[];
/** Disable built-in tools for this run (LLM-only mode). */
disableTools?: boolean;
provider?: string;
model?: string;
authProfileId?: string;

View File

@@ -36,6 +36,8 @@ export type EmbeddedRunAttemptParams = {
images?: ImageContent[];
/** Optional client-provided tools (OpenResponses hosted tools). */
clientTools?: ClientToolDefinition[];
/** Disable built-in tools for this run (LLM-only mode). */
disableTools?: boolean;
provider: string;
modelId: string;
model: Model<Api>;

View File

@@ -0,0 +1,36 @@
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { filterToolsByPolicy, isToolAllowedByPolicyName } from "./pi-tools.policy.js";
function createStubTool(name: string): AgentTool<unknown, unknown> {
return {
name,
label: name,
description: "",
parameters: {},
execute: async () => ({}) as AgentToolResult<unknown>,
};
}
describe("pi-tools.policy", () => {
it("treats * in allow as allow-all", () => {
const tools = [createStubTool("read"), createStubTool("exec")];
const filtered = filterToolsByPolicy(tools, { allow: ["*"] });
expect(filtered.map((tool) => tool.name)).toEqual(["read", "exec"]);
});
it("treats * in deny as deny-all", () => {
const tools = [createStubTool("read"), createStubTool("exec")];
const filtered = filterToolsByPolicy(tools, { deny: ["*"] });
expect(filtered).toEqual([]);
});
it("supports wildcard allow/deny patterns", () => {
expect(isToolAllowedByPolicyName("web_fetch", { allow: ["web_*"] })).toBe(true);
expect(isToolAllowedByPolicyName("web_search", { deny: ["web_*"] })).toBe(false);
});
it("keeps apply_patch when exec is allowlisted", () => {
expect(isToolAllowedByPolicyName("apply_patch", { allow: ["exec"] })).toBe(true);
});
});

View File

@@ -4,6 +4,52 @@ import type { AnyAgentTool } from "./pi-tools.types.js";
import type { SandboxToolPolicy } from "./sandbox.js";
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
type CompiledPattern =
| { kind: "all" }
| { kind: "exact"; value: string }
| { kind: "regex"; value: RegExp };
function compilePattern(pattern: string): CompiledPattern {
const normalized = normalizeToolName(pattern);
if (!normalized) return { kind: "exact", value: "" };
if (normalized === "*") return { kind: "all" };
if (!normalized.includes("*")) return { kind: "exact", value: normalized };
const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return {
kind: "regex",
value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`),
};
}
function compilePatterns(patterns?: string[]): CompiledPattern[] {
if (!Array.isArray(patterns)) return [];
return expandToolGroups(patterns)
.map(compilePattern)
.filter((pattern) => pattern.kind !== "exact" || pattern.value);
}
function matchesAny(name: string, patterns: CompiledPattern[]): boolean {
for (const pattern of patterns) {
if (pattern.kind === "all") return true;
if (pattern.kind === "exact" && name === pattern.value) return true;
if (pattern.kind === "regex" && pattern.value.test(name)) return true;
}
return false;
}
function makeToolPolicyMatcher(policy: SandboxToolPolicy) {
const deny = compilePatterns(policy.deny);
const allow = compilePatterns(policy.allow);
return (name: string) => {
const normalized = normalizeToolName(name);
if (matchesAny(normalized, deny)) return false;
if (allow.length === 0) return true;
if (matchesAny(normalized, allow)) return true;
if (normalized === "apply_patch" && matchesAny("exec", allow)) return true;
return false;
};
}
const DEFAULT_SUBAGENT_TOOL_DENY = [
// Session management - main agent orchestrates
"sessions_list",
@@ -35,22 +81,13 @@ export function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPoli
export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean {
if (!policy) return true;
const deny = new Set(expandToolGroups(policy.deny));
const allowRaw = expandToolGroups(policy.allow);
const allow = allowRaw.length > 0 ? new Set(allowRaw) : null;
const normalized = normalizeToolName(name);
if (deny.has(normalized)) return false;
if (allow) {
if (allow.has(normalized)) return true;
if (normalized === "apply_patch" && allow.has("exec")) return true;
return false;
}
return true;
return makeToolPolicyMatcher(policy)(name);
}
export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolPolicy) {
if (!policy) return tools;
return tools.filter((tool) => isToolAllowedByPolicyName(tool.name, policy));
const matcher = makeToolPolicyMatcher(policy);
return tools.filter((tool) => matcher(tool.name));
}
type ToolPolicyConfig = {

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import type { SandboxToolPolicy } from "./types.js";
import { isToolAllowed } from "./tool-policy.js";
describe("sandbox tool policy", () => {
it("allows all tools with * allow", () => {
const policy: SandboxToolPolicy = { allow: ["*"], deny: [] };
expect(isToolAllowed(policy, "browser")).toBe(true);
});
it("denies all tools with * deny", () => {
const policy: SandboxToolPolicy = { allow: [], deny: ["*"] };
expect(isToolAllowed(policy, "read")).toBe(false);
});
it("supports wildcard patterns", () => {
const policy: SandboxToolPolicy = { allow: ["web_*"] };
expect(isToolAllowed(policy, "web_fetch")).toBe(true);
expect(isToolAllowed(policy, "read")).toBe(false);
});
});

View File

@@ -8,12 +8,46 @@ import type {
SandboxToolPolicySource,
} from "./types.js";
type CompiledPattern =
| { kind: "all" }
| { kind: "exact"; value: string }
| { kind: "regex"; value: RegExp };
function compilePattern(pattern: string): CompiledPattern {
const normalized = pattern.trim().toLowerCase();
if (!normalized) return { kind: "exact", value: "" };
if (normalized === "*") return { kind: "all" };
if (!normalized.includes("*")) return { kind: "exact", value: normalized };
const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return {
kind: "regex",
value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`),
};
}
function compilePatterns(patterns?: string[]): CompiledPattern[] {
if (!Array.isArray(patterns)) return [];
return expandToolGroups(patterns)
.map(compilePattern)
.filter((pattern) => pattern.kind !== "exact" || pattern.value);
}
function matchesAny(name: string, patterns: CompiledPattern[]): boolean {
for (const pattern of patterns) {
if (pattern.kind === "all") return true;
if (pattern.kind === "exact" && name === pattern.value) return true;
if (pattern.kind === "regex" && pattern.value.test(name)) return true;
}
return false;
}
export function isToolAllowed(policy: SandboxToolPolicy, name: string) {
const deny = new Set(expandToolGroups(policy.deny));
if (deny.has(name.toLowerCase())) return false;
const allow = expandToolGroups(policy.allow);
const normalized = name.trim().toLowerCase();
const deny = compilePatterns(policy.deny);
if (matchesAny(normalized, deny)) return false;
const allow = compilePatterns(policy.allow);
if (allow.length === 0) return true;
return allow.includes(name.toLowerCase());
return matchesAny(normalized, allow);
}
export function resolveSandboxToolPolicyForAgent(

View File

@@ -81,6 +81,14 @@ export async function extractReadableContent(params: {
url: string;
extractMode: ExtractMode;
}): Promise<{ text: string; title?: string } | null> {
const fallback = (): { text: string; title?: string } => {
const rendered = htmlToMarkdown(params.html);
if (params.extractMode === "text") {
const text = markdownToText(rendered.text) || normalizeWhitespace(stripTags(params.html));
return { text, title: rendered.title };
}
return rendered;
};
try {
const [{ Readability }, { parseHTML }] = await Promise.all([
import("@mozilla/readability"),
@@ -94,15 +102,15 @@ export async function extractReadableContent(params: {
}
const reader = new Readability(document, { charThreshold: 0 });
const parsed = reader.parse();
if (!parsed?.content) return null;
if (!parsed?.content) return fallback();
const title = parsed.title || undefined;
if (params.extractMode === "text") {
const text = normalizeWhitespace(parsed.textContent ?? "");
return { text, title };
return text ? { text, title } : fallback();
}
const rendered = htmlToMarkdown(parsed.content);
return { text: rendered.text, title: title ?? rendered.title };
} catch {
return null;
return fallback();
}
}

View File

@@ -97,7 +97,7 @@ afterEach(() => {
describe("trigger handling", () => {
it("includes the error cause when the embedded agent throws", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(new Error("sandbox is not defined"));
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(new Error("sandbox is not defined."));
const res = await getReplyFromConfig(
{
@@ -111,7 +111,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe(
"⚠️ Agent failed before reply: sandbox is not defined. Check gateway logs for details.",
"⚠️ Agent failed before reply: sandbox is not defined.\nLogs: clawdbot logs --follow",
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});

View File

@@ -507,14 +507,17 @@ export async function runAgentTurnWithFallback(params: {
}
defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
const trimmedMessage = message.replace(/\.\s*$/, "");
const fallbackText = isContextOverflow
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
: isRoleOrderingError
? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
: `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: clawdbot logs --follow`;
return {
kind: "final",
payload: {
text: isContextOverflow
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
: isRoleOrderingError
? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
: `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`,
text: fallbackText,
},
};
}

View File

@@ -51,14 +51,14 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
const line1 = `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`,
)}`;
const line2 = `${" ".repeat(prefix.length)}${theme.muted("—")} ${theme.accentDim(tagline)}`;
const line2 = `${" ".repeat(prefix.length)}${theme.accentDim(tagline)}`;
return `${line1}\n${line2}`;
}
if (fitsOnOneLine) {
return plainFullLine;
}
const line1 = `${title} ${version} (${commitLabel})`;
const line2 = `${" ".repeat(prefix.length)}${tagline}`;
const line2 = `${" ".repeat(prefix.length)}${tagline}`;
return `${line1}\n${line2}`;
}

View File

@@ -11,6 +11,7 @@ const resolveAuthStorePathForDisplay = vi
.mockReturnValue("/tmp/clawdbot-agent/auth-profiles.json");
const resolveProfileUnusableUntilForDisplay = vi.fn().mockReturnValue(null);
const resolveEnvApiKey = vi.fn().mockReturnValue(undefined);
const resolveAwsSdkEnvVarName = vi.fn().mockReturnValue(undefined);
const getCustomProviderApiKey = vi.fn().mockReturnValue(undefined);
const discoverAuthStorage = vi.fn().mockReturnValue({});
const discoverModels = vi.fn();
@@ -39,6 +40,7 @@ vi.mock("../agents/auth-profiles.js", () => ({
vi.mock("../agents/model-auth.js", () => ({
resolveEnvApiKey,
resolveAwsSdkEnvVarName,
getCustomProviderApiKey,
}));

View File

@@ -6,6 +6,7 @@ import {
ensureAuthProfileStore,
listProfilesForProvider,
resolveAuthProfileDisplayLabel,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { describeFailoverError } from "../../agents/failover-error.js";
@@ -143,6 +144,25 @@ function buildProbeTargets(params: {
});
const profileIds = listProfilesForProvider(store, providerKey);
const explicitOrder = (() => {
const order = store.order;
if (order) {
for (const [key, value] of Object.entries(order)) {
if (normalizeProviderId(key) === providerKey) return value;
}
}
const cfgOrder = cfg?.auth?.order;
if (cfgOrder) {
for (const [key, value] of Object.entries(cfgOrder)) {
if (normalizeProviderId(key) === providerKey) return value;
}
}
return undefined;
})();
const allowedProfiles =
explicitOrder && explicitOrder.length > 0
? new Set(resolveAuthProfileOrder({ cfg, store, provider: providerKey }))
: null;
const filteredProfiles = profileFilter.size
? profileIds.filter((id) => profileFilter.has(id))
: profileIds;
@@ -152,6 +172,32 @@ function buildProbeTargets(params: {
const profile = store.profiles[profileId];
const mode = profile?.type;
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
if (explicitOrder && !explicitOrder.includes(profileId)) {
results.push({
provider: providerKey,
model: model ? `${model.provider}/${model.model}` : undefined,
profileId,
label,
source: "profile",
mode,
status: "unknown",
error: "Excluded by auth.order for this provider.",
});
continue;
}
if (allowedProfiles && !allowedProfiles.has(profileId)) {
results.push({
provider: providerKey,
model: model ? `${model.provider}/${model.model}` : undefined,
profileId,
label,
source: "profile",
mode,
status: "unknown",
error: "Auth profile credentials are missing or expired.",
});
continue;
}
if (!model) {
results.push({
provider: providerKey,

View File

@@ -4,7 +4,11 @@ import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-age
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
import { listProfilesForProvider } from "../../agents/auth-profiles.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import {
getCustomProviderApiKey,
resolveAwsSdkEnvVarName,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { ensureClawdbotModelsJson } from "../../agents/models-config.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { ModelRow } from "./list.types.js";
@@ -28,6 +32,7 @@ const isLocalBaseUrl = (baseUrl: string) => {
const hasAuthForProvider = (provider: string, cfg: ClawdbotConfig, authStore: AuthProfileStore) => {
if (listProfilesForProvider(authStore, provider).length > 0) return true;
if (provider === "amazon-bedrock" && resolveAwsSdkEnvVarName()) return true;
if (resolveEnvApiKey(provider)) return true;
if (getCustomProviderApiKey(cfg, provider)) return true;
return false;

View File

@@ -587,7 +587,9 @@ export async function modelsStatusCommand(
const modelLabel = result.model ?? `${result.provider}/-`;
const modeLabel = result.mode ? ` ${colorize(rich, theme.muted, `(${result.mode})`)}` : "";
const profile = `${colorize(rich, theme.accent, result.label)}${modeLabel}`;
const statusLabel = `${status}${colorize(rich, theme.muted, ` · ${latency}`)}`;
const detail = result.error?.trim();
const detailLabel = detail ? `\n${colorize(rich, theme.muted, `${detail}`)}` : "";
const statusLabel = `${status}${colorize(rich, theme.muted, ` · ${latency}`)}${detailLabel}`;
return {
Model: colorize(rich, theme.heading, modelLabel),
Profile: profile,
@@ -605,18 +607,6 @@ export async function modelsStatusCommand(
rows,
}).trimEnd(),
);
const detailRows = sorted.filter((result) => Boolean(result.error?.trim()));
if (detailRows.length > 0) {
runtime.log("");
runtime.log(colorize(rich, theme.muted, "Details"));
for (const result of detailRows) {
const modelLabel = colorize(rich, theme.heading, result.model ?? `${result.provider}/-`);
const profileLabel = colorize(rich, theme.accent, result.label);
runtime.log(
`- ${modelLabel} ${profileLabel}: ${colorize(rich, theme.muted, result.error ?? "")}`,
);
}
}
runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary)));
}
}

View File

@@ -43,7 +43,17 @@ export type ModelProviderConfig = {
models: ModelDefinitionConfig[];
};
export type BedrockDiscoveryConfig = {
enabled?: boolean;
region?: string;
providerFilter?: string[];
refreshInterval?: number;
defaultContextWindow?: number;
defaultMaxTokens?: number;
};
export type ModelsConfig = {
mode?: "merge" | "replace";
providers?: Record<string, ModelProviderConfig>;
bedrockDiscovery?: BedrockDiscoveryConfig;
};

View File

@@ -59,10 +59,23 @@ export const ModelProviderSchema = z
})
.strict();
export const BedrockDiscoverySchema = z
.object({
enabled: z.boolean().optional(),
region: z.string().optional(),
providerFilter: z.array(z.string()).optional(),
refreshInterval: z.number().int().nonnegative().optional(),
defaultContextWindow: z.number().int().positive().optional(),
defaultMaxTokens: z.number().int().positive().optional(),
})
.strict()
.optional();
export const ModelsConfigSchema = z
.object({
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
providers: z.record(z.string(), ModelProviderSchema).optional(),
bedrockDiscovery: BedrockDiscoverySchema,
})
.strict()
.optional();

View File

@@ -122,11 +122,14 @@ describe("getMinimalServicePathParts - Linux user directories", () => {
});
describe("buildMinimalServicePath", () => {
const splitPath = (value: string, platform: NodeJS.Platform) =>
value.split(platform === "win32" ? path.win32.delimiter : path.posix.delimiter);
it("includes Homebrew + system dirs on macOS", () => {
const result = buildMinimalServicePath({
platform: "darwin",
});
const parts = result.split(path.delimiter);
const parts = splitPath(result, "darwin");
expect(parts).toContain("/opt/homebrew/bin");
expect(parts).toContain("/usr/local/bin");
expect(parts).toContain("/usr/bin");
@@ -146,7 +149,7 @@ describe("buildMinimalServicePath", () => {
platform: "linux",
env: { HOME: "/home/alice" },
});
const parts = result.split(path.delimiter);
const parts = splitPath(result, "linux");
// Verify user directories are included
expect(parts).toContain("/home/alice/.local/bin");
@@ -164,7 +167,7 @@ describe("buildMinimalServicePath", () => {
platform: "linux",
env: {},
});
const parts = result.split(path.delimiter);
const parts = splitPath(result, "linux");
// Should only have system directories
expect(parts).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]);
@@ -178,7 +181,7 @@ describe("buildMinimalServicePath", () => {
platform: "linux",
env: { HOME: "/home/bob" },
});
const parts = result.split(path.delimiter);
const parts = splitPath(result, "linux");
const firstUserDirIdx = parts.indexOf("/home/bob/.local/bin");
const firstSystemDirIdx = parts.indexOf("/usr/local/bin");
@@ -191,7 +194,7 @@ describe("buildMinimalServicePath", () => {
platform: "linux",
extraDirs: ["/custom/tools"],
});
expect(result.split(path.delimiter)).toContain("/custom/tools");
expect(splitPath(result, "linux")).toContain("/custom/tools");
});
it("deduplicates directories", () => {
@@ -199,7 +202,7 @@ describe("buildMinimalServicePath", () => {
platform: "linux",
extraDirs: ["/usr/bin"],
});
const parts = result.split(path.delimiter);
const parts = splitPath(result, "linux");
const unique = [...new Set(parts)];
expect(parts.length).toBe(unique.length);
});

View File

@@ -121,7 +121,7 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}):
return env.PATH ?? "";
}
return getMinimalServicePathPartsFromEnv({ ...options, env }).join(path.delimiter);
return getMinimalServicePathPartsFromEnv({ ...options, env }).join(path.posix.delimiter);
}
export function buildServiceEnvironment(params: {

View File

@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./config.js", () => ({
readLoggingConfig: () => undefined,
}));
vi.mock("./logger.js", () => ({
getLogger: () => ({
trace: () => {},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
fatal: () => {},
}),
}));
let loadConfigCalls = 0;
vi.mock("node:module", async () => {
const actual = await vi.importActual<typeof import("node:module")>("node:module");
return Object.assign({}, actual, {
createRequire: (url: string | URL) => {
const realRequire = actual.createRequire(url);
return (specifier: string) => {
if (specifier.endsWith("config.js")) {
return {
loadConfig: () => {
loadConfigCalls += 1;
if (loadConfigCalls > 5) {
return {};
}
console.error("config load failed");
return {};
},
};
}
return realRequire(specifier);
};
},
});
});
type ConsoleSnapshot = {
log: typeof console.log;
info: typeof console.info;
warn: typeof console.warn;
error: typeof console.error;
debug: typeof console.debug;
trace: typeof console.trace;
};
let originalIsTty: boolean | undefined;
let snapshot: ConsoleSnapshot;
beforeEach(() => {
loadConfigCalls = 0;
vi.resetModules();
snapshot = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
trace: console.trace,
};
originalIsTty = process.stdout.isTTY;
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
});
afterEach(() => {
console.log = snapshot.log;
console.info = snapshot.info;
console.warn = snapshot.warn;
console.error = snapshot.error;
console.debug = snapshot.debug;
console.trace = snapshot.trace;
Object.defineProperty(process.stdout, "isTTY", { value: originalIsTty, configurable: true });
vi.restoreAllMocks();
});
async function loadLogging() {
const logging = await import("../logging.js");
const state = await import("./state.js");
state.loggingState.cachedConsoleSettings = null;
return { logging, state };
}
describe("getConsoleSettings", () => {
it("does not recurse when loadConfig logs during resolution", async () => {
const { logging } = await loadLogging();
logging.setConsoleTimestampPrefix(true);
logging.enableConsoleCapture();
const { getConsoleSettings } = logging;
getConsoleSettings();
expect(loadConfigCalls).toBe(1);
});
it("skips config fallback during re-entrant resolution", async () => {
const { logging, state } = await loadLogging();
state.loggingState.resolvingConsoleSettings = true;
logging.setConsoleTimestampPrefix(true);
logging.enableConsoleCapture();
logging.getConsoleSettings();
expect(loadConfigCalls).toBe(0);
state.loggingState.resolvingConsoleSettings = false;
});
});

View File

@@ -35,13 +35,20 @@ function resolveConsoleSettings(): ConsoleSettings {
let cfg: ClawdbotConfig["logging"] | undefined =
(loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig();
if (!cfg) {
try {
const loaded = requireConfig("../config/config.js") as {
loadConfig?: () => ClawdbotConfig;
};
cfg = loaded.loadConfig?.().logging;
} catch {
if (loggingState.resolvingConsoleSettings) {
cfg = undefined;
} else {
loggingState.resolvingConsoleSettings = true;
try {
const loaded = requireConfig("../config/config.js") as {
loadConfig?: () => ClawdbotConfig;
};
cfg = loaded.loadConfig?.().logging;
} catch {
cfg = undefined;
} finally {
loggingState.resolvingConsoleSettings = false;
}
}
}
const level = normalizeConsoleLevel(cfg?.consoleLevel);

View File

@@ -7,6 +7,7 @@ export const loggingState = {
forceConsoleToStderr: false,
consoleTimestampPrefix: false,
consoleSubsystemFilter: null as string[] | null,
resolvingConsoleSettings: false,
rawConsole: null as {
log: typeof console.log;
info: typeof console.info;

View File

@@ -382,6 +382,65 @@ export async function resizeToPng(params: {
.toBuffer();
}
export async function optimizeImageToPng(
buffer: Buffer,
maxBytes: number,
): Promise<{
buffer: Buffer;
optimizedSize: number;
resizeSide: number;
compressionLevel: number;
}> {
// Try a grid of sizes/compression levels until under the limit.
// PNG uses compression levels 0-9 (higher = smaller but slower).
const sides = [2048, 1536, 1280, 1024, 800];
const compressionLevels = [6, 7, 8, 9];
let smallest: {
buffer: Buffer;
size: number;
resizeSide: number;
compressionLevel: number;
} | null = null;
for (const side of sides) {
for (const compressionLevel of compressionLevels) {
try {
const out = await resizeToPng({
buffer,
maxSide: side,
compressionLevel,
withoutEnlargement: true,
});
const size = out.length;
if (!smallest || size < smallest.size) {
smallest = { buffer: out, size, resizeSide: side, compressionLevel };
}
if (size <= maxBytes) {
return {
buffer: out,
optimizedSize: size,
resizeSide: side,
compressionLevel,
};
}
} catch {
// Continue trying other size/compression combinations.
}
}
}
if (smallest) {
return {
buffer: smallest.buffer,
optimizedSize: smallest.size,
resizeSide: smallest.resizeSide,
compressionLevel: smallest.compressionLevel,
};
}
throw new Error("Failed to optimize PNG image");
}
/**
* Internal sips-only EXIF normalization (no sharp fallback).
* Used by resizeToJpeg to normalize before sips resize.

View File

@@ -85,6 +85,31 @@ describe("renderTable", () => {
}
});
it("resets ANSI styling on wrapped lines", () => {
const reset = "\x1b[0m";
const out = renderTable({
width: 24,
columns: [
{ key: "K", header: "K", minWidth: 3 },
{ key: "V", header: "V", flex: true, minWidth: 10 },
],
rows: [
{
K: "X",
V: `\x1b[31m${"a".repeat(80)}${reset}`,
},
],
});
const lines = out.split("\n").filter((line) => line.includes("a"));
for (const line of lines) {
const resetIndex = line.lastIndexOf(reset);
const lastSep = line.lastIndexOf("│");
expect(resetIndex).toBeGreaterThan(-1);
expect(lastSep).toBeGreaterThan(resetIndex);
}
});
it("respects explicit newlines in cell values", () => {
const out = renderTable({
width: 48,

View File

@@ -91,6 +91,27 @@ function wrapLine(text: string, width: number): string[] {
i += ch.length;
}
const firstCharIndex = tokens.findIndex((t) => t.kind === "char");
if (firstCharIndex < 0) return [text];
let lastCharIndex = -1;
for (let i = tokens.length - 1; i >= 0; i -= 1) {
if (tokens[i]?.kind === "char") {
lastCharIndex = i;
break;
}
}
const prefixAnsi = tokens
.slice(0, firstCharIndex)
.filter((t) => t.kind === "ansi")
.map((t) => t.value)
.join("");
const suffixAnsi = tokens
.slice(lastCharIndex + 1)
.filter((t) => t.kind === "ansi")
.map((t) => t.value)
.join("");
const coreTokens = tokens.slice(firstCharIndex, lastCharIndex + 1);
const lines: string[] = [];
const isBreakChar = (ch: string) =>
ch === " " || ch === "\t" || ch === "/" || ch === "-" || ch === "_" || ch === ".";
@@ -136,7 +157,7 @@ function wrapLine(text: string, width: number): string[] {
lastBreakIndex = null;
};
for (const token of tokens) {
for (const token of coreTokens) {
if (token.kind === "ansi") {
buf.push(token);
continue;
@@ -162,7 +183,12 @@ function wrapLine(text: string, width: number): string[] {
}
flushAt(buf.length);
return lines.length ? lines : [""];
if (!lines.length) return [""];
if (!prefixAnsi && !suffixAnsi) return lines;
return lines.map((line) => {
if (!line) return line;
return `${prefixAnsi}${line}${suffixAnsi}`;
});
}
function normalizeWidth(n: number | undefined): number | undefined {

View File

@@ -5,10 +5,21 @@ import path from "node:path";
import sharp from "sharp";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadWebMedia, optimizeImageToJpeg, optimizeImageToPng } from "./media.js";
import { optimizeImageToPng } from "../media/image-ops.js";
import { loadWebMedia, optimizeImageToJpeg } from "./media.js";
const tmpFiles: string[] = [];
async function writeTempFile(buffer: Buffer, ext: string): Promise<string> {
const file = path.join(
os.tmpdir(),
`clawdbot-media-${Date.now()}-${Math.random().toString(16).slice(2)}${ext}`,
);
tmpFiles.push(file);
await fs.writeFile(file, buffer);
return file;
}
function buildDeterministicBytes(length: number): Buffer {
const buffer = Buffer.allocUnsafe(length);
let seed = 0x12345678;
@@ -37,9 +48,7 @@ describe("web media loading", () => {
.jpeg({ quality: 95 })
.toBuffer();
const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.jpg`);
tmpFiles.push(file);
await fs.writeFile(file, buffer);
const file = await writeTempFile(buffer, ".jpg");
const cap = Math.floor(buffer.length * 0.8);
const result = await loadWebMedia(file, cap);
@@ -55,9 +64,7 @@ describe("web media loading", () => {
})
.png()
.toBuffer();
const wrongExt = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.bin`);
tmpFiles.push(wrongExt);
await fs.writeFile(wrongExt, pngBuffer);
const wrongExt = await writeTempFile(pngBuffer, ".bin");
const result = await loadWebMedia(wrongExt, 1024 * 1024);
@@ -160,9 +167,7 @@ describe("web media loading", () => {
0x3b, // minimal LZW data + trailer
]);
const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.gif`);
tmpFiles.push(file);
await fs.writeFile(file, gifBuffer);
const file = await writeTempFile(gifBuffer, ".gif");
const result = await loadWebMedia(file, 1024 * 1024);
@@ -208,9 +213,7 @@ describe("web media loading", () => {
.png()
.toBuffer();
const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}.png`);
tmpFiles.push(file);
await fs.writeFile(file, buffer);
const file = await writeTempFile(buffer, ".png");
const result = await loadWebMedia(file, 1024 * 1024);
@@ -250,9 +253,7 @@ describe("web media loading", () => {
);
}
const file = path.join(os.tmpdir(), `clawdbot-media-${Date.now()}-alpha.png`);
tmpFiles.push(file);
await fs.writeFile(file, pngBuffer);
const file = await writeTempFile(pngBuffer, ".png");
const result = await loadWebMedia(file, cap);

View File

@@ -9,8 +9,8 @@ import { fetchRemoteMedia } from "../media/fetch.js";
import {
convertHeicToJpeg,
hasAlphaChannel,
optimizeImageToPng,
resizeToJpeg,
resizeToPng,
} from "../media/image-ops.js";
import { detectMime, extensionForMime } from "../media/mime.js";
@@ -28,6 +28,19 @@ type WebMediaOptions = {
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
const HEIC_EXT_RE = /\.(heic|heif)$/i;
const MB = 1024 * 1024;
function formatMb(bytes: number, digits = 2): string {
return (bytes / MB).toFixed(digits);
}
function formatCapLimit(label: string, cap: number, size: number): string {
return `${label} exceeds ${formatMb(cap, 0)}MB limit (got ${formatMb(size)}MB)`;
}
function formatCapReduce(label: string, cap: number, size: number): string {
return `${label} could not be reduced below ${formatMb(cap, 0)}MB (got ${formatMb(size)}MB)`;
}
function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean {
if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) return true;
@@ -46,6 +59,54 @@ function toJpegFileName(fileName?: string): string | undefined {
return path.format({ dir: parsed.dir, name: parsed.name, ext: ".jpg" });
}
type OptimizedImage = {
buffer: Buffer;
optimizedSize: number;
resizeSide: number;
format: "jpeg" | "png";
quality?: number;
compressionLevel?: number;
};
function logOptimizedImage(params: { originalSize: number; optimized: OptimizedImage }): void {
if (!shouldLogVerbose()) return;
if (params.optimized.optimizedSize >= params.originalSize) return;
if (params.optimized.format === "png") {
logVerbose(
`Optimized PNG (preserving alpha) from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px)`,
);
return;
}
logVerbose(
`Optimized media from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px, q=${params.optimized.quality})`,
);
}
async function optimizeImageWithFallback(params: {
buffer: Buffer;
cap: number;
meta?: { contentType?: string; fileName?: string };
}): Promise<OptimizedImage> {
const { buffer, cap, meta } = params;
const isPng = meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png");
const hasAlpha = isPng && (await hasAlphaChannel(buffer));
if (hasAlpha) {
const optimized = await optimizeImageToPng(buffer, cap);
if (optimized.buffer.length <= cap) {
return { ...optimized, format: "png" };
}
if (shouldLogVerbose()) {
logVerbose(
`PNG with alpha still exceeds ${formatMb(cap, 0)}MB after optimization; falling back to JPEG`,
);
}
}
const optimized = await optimizeImageToJpeg(buffer, cap, meta);
return { ...optimized, format: "jpeg" };
}
async function loadWebMediaInternal(
mediaUrl: string,
options: WebMediaOptions = {},
@@ -66,59 +127,25 @@ async function loadWebMediaInternal(
meta?: { contentType?: string; fileName?: string },
) => {
const originalSize = buffer.length;
const optimized = await optimizeImageWithFallback({ buffer, cap, meta });
logOptimizedImage({ originalSize, optimized });
const optimizeToJpeg = async () => {
const optimized = await optimizeImageToJpeg(buffer, cap, meta);
const fileName = meta && isHeicSource(meta) ? toJpegFileName(meta.fileName) : meta?.fileName;
if (optimized.optimizedSize < originalSize && shouldLogVerbose()) {
logVerbose(
`Optimized media from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px, q=${optimized.quality})`,
);
}
if (optimized.buffer.length > cap) {
throw new Error(
`Media could not be reduced below ${(cap / (1024 * 1024)).toFixed(0)}MB (got ${(
optimized.buffer.length /
(1024 * 1024)
).toFixed(2)}MB)`,
);
}
return {
buffer: optimized.buffer,
contentType: "image/jpeg",
kind: "image" as const,
fileName,
};
};
// Check if this is a PNG with alpha channel - preserve transparency when possible
const isPng =
meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png");
const hasAlpha = isPng && (await hasAlphaChannel(buffer));
if (hasAlpha) {
const optimized = await optimizeImageToPng(buffer, cap);
if (optimized.buffer.length <= cap) {
if (optimized.optimizedSize < originalSize && shouldLogVerbose()) {
logVerbose(
`Optimized PNG (preserving alpha) from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px)`,
);
}
return {
buffer: optimized.buffer,
contentType: "image/png",
kind: "image" as const,
fileName: meta?.fileName,
};
}
if (shouldLogVerbose()) {
logVerbose(
`PNG with alpha still exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB after optimization; falling back to JPEG`,
);
}
if (optimized.buffer.length > cap) {
throw new Error(formatCapReduce("Media", cap, optimized.buffer.length));
}
return await optimizeToJpeg();
const contentType = optimized.format === "png" ? "image/png" : "image/jpeg";
const fileName =
optimized.format === "jpeg" && meta && isHeicSource(meta)
? toJpegFileName(meta.fileName)
: meta?.fileName;
return {
buffer: optimized.buffer,
contentType,
kind: "image" as const,
fileName,
};
};
const clampAndFinalize = async (params: {
@@ -134,12 +161,7 @@ async function loadWebMediaInternal(
const isGif = params.contentType === "image/gif";
if (isGif || !optimizeImages) {
if (params.buffer.length > cap) {
throw new Error(
`${isGif ? "GIF" : "Media"} exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
params.buffer.length /
(1024 * 1024)
).toFixed(2)}MB)`,
);
throw new Error(formatCapLimit(isGif ? "GIF" : "Media", cap, params.buffer.length));
}
return {
buffer: params.buffer,
@@ -156,12 +178,7 @@ async function loadWebMediaInternal(
};
}
if (params.buffer.length > cap) {
throw new Error(
`Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
params.buffer.length /
(1024 * 1024)
).toFixed(2)}MB)`,
);
throw new Error(formatCapLimit("Media", cap, params.buffer.length));
}
return {
buffer: params.buffer,
@@ -284,61 +301,4 @@ export async function optimizeImageToJpeg(
throw new Error("Failed to optimize image");
}
export async function optimizeImageToPng(
buffer: Buffer,
maxBytes: number,
): Promise<{
buffer: Buffer;
optimizedSize: number;
resizeSide: number;
compressionLevel: number;
}> {
// Try a grid of sizes/compression levels until under the limit.
// PNG uses compression levels 0-9 (higher = smaller but slower)
const sides = [2048, 1536, 1280, 1024, 800];
const compressionLevels = [6, 7, 8, 9];
let smallest: {
buffer: Buffer;
size: number;
resizeSide: number;
compressionLevel: number;
} | null = null;
for (const side of sides) {
for (const compressionLevel of compressionLevels) {
try {
const out = await resizeToPng({
buffer,
maxSide: side,
compressionLevel,
withoutEnlargement: true,
});
const size = out.length;
if (!smallest || size < smallest.size) {
smallest = { buffer: out, size, resizeSide: side, compressionLevel };
}
if (size <= maxBytes) {
return {
buffer: out,
optimizedSize: size,
resizeSide: side,
compressionLevel,
};
}
} catch {
// Continue trying other size/compression combinations
}
}
}
if (smallest) {
return {
buffer: smallest.buffer,
optimizedSize: smallest.size,
resizeSide: smallest.resizeSide,
compressionLevel: smallest.compressionLevel,
};
}
throw new Error("Failed to optimize PNG image");
}
export { optimizeImageToPng };