49 Commits

Author SHA1 Message Date
6c17462040 feat: Added todo__clear function to the todo system and updated REPL commands to have a .clear todo as well for significant changes in agent direction
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-04-02 13:13:44 -06:00
1536cf384c fix: Clarified user text input interaction
CI / All (ubuntu-latest) (push) Failing after 23s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-30 16:27:22 -06:00
d6842d7e29 fix: recursion bug with similarly named Bash search functions in the explore agent
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-30 13:32:13 -06:00
fbc0acda2a feat: Added available tools to prompts for sisyphus and code-reviewer agent families 2026-03-30 13:13:30 -06:00
0327d041b6 feat: Added available tools to coder prompt 2026-03-30 11:11:43 -06:00
6a01fd4fbd Merge branch 'main' of github.com:Dark-Alex-17/loki 2026-03-30 10:15:51 -06:00
d822180205 fix: updated the error for unauthenticated oauth to include the REPL .authenticated command 2026-03-28 11:57:01 -06:00
89d0fdce26 feat: Improved token efficiency when delegating from sisyphus -> coder 2026-03-18 15:07:29 -06:00
b3ecdce979 build: Removed deprecated agent functions from the .shared/utils.sh script 2026-03-18 15:04:14 -06:00
3873821a31 fix: Corrected a bug in the coder agent that wasn't outputting a summary of the changes made, so the parent Sisyphus agent has no idea if the agent worked or not
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-17 14:57:07 -06:00
9c2801b643 feat: modified sisyphus agents to use the new ddg-search MCP server for web searches instead of built-in model searches 2026-03-17 14:55:33 -06:00
d78820dcd4 fix: Claude code system prompt injected into claude requests to make them valid once again
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-17 10:44:50 -06:00
d43c4232a2 fix: Do not inject tools when models don't support them; detect this conflict before API calls happen 2026-03-17 09:35:51 -06:00
f41c85b703 style: Applied formatting across new inquire files
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-16 12:39:20 -06:00
9e056bdcf0 feat: Added support for specifying a custom response to multiple-choice prompts when nothing suits the user's needs 2026-03-16 12:37:47 -06:00
d6022b9f98 feat: Supported theming in the inquire prompts in the REPL 2026-03-16 12:36:20 -06:00
6fc1abf94a build: upgraded to the most recent version of the inquire crate 2026-03-16 12:31:28 -06:00
92ea0f624e docs: Fixed a spacing issue in the example agent configuration
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-13 14:19:39 -06:00
c3fd8fbc1c docs: Added the file-reviewer agent to the AGENTS docs
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-13 14:07:13 -06:00
7fd3f7761c docs: Updated the MCP-SERVERS docs to mention the ddg-search MCP server
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-13 13:32:58 -06:00
05e19098b2 feat: Added the duckduckgo-search MCP server for searching the web (in addition to the built-in tools for web searches)
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-13 13:29:56 -06:00
60067ae757 Merge branch 'main' of github.com:Dark-Alex-17/loki
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-12 15:17:54 -06:00
c72003b0b6 fix: Implemented the path normalization fix for the oracle and explore agents 2026-03-12 13:38:15 -06:00
7c9d500116 chore: Added GPT-5.2 to models.yaml 2026-03-12 13:30:23 -06:00
6b2c87b562 docs: Updated the docs to now explicitly mention Gemini OAuth support 2026-03-12 13:30:10 -06:00
b2dbdfb4b1 feat: Support for Gemini OAuth 2026-03-12 13:29:47 -06:00
063e198f96 refactor: Made the oauth module more generic so it can support loopback OAuth (not just manual) 2026-03-12 13:28:09 -06:00
73cbe16ec1 fix: Updated the atlassian MCP server endpoint to account for future deprecation 2026-03-12 12:49:26 -06:00
bdea854a9f fix: Fixed a bug in the coder agent that was causing the agent to create absolute paths from the current directory 2026-03-12 12:39:49 -06:00
9b4c800597 fix: The REPL .authenticate command works from within sessions, agents, and roles with pre-configured models
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-12 09:08:17 -06:00
eb4d1c02f4 feat: Support authenticating or refreshing OAuth for supported clients from within the REPL
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-11 13:07:27 -06:00
c428990900 fix: the updated regex for secrets injection broke MCP server secrets interpolation because the regex greedily matched on new lines, replacing too much content. This fix just ignores commented out lines in YAML files by skipping commented out lines. 2026-03-11 12:55:28 -06:00
03b9cc70b9 feat: Allow first-runs to select OAuth for supported providers 2026-03-11 12:01:17 -06:00
3fa0eb832c fix: Don't try to inject secrets into commented-out lines in the config 2026-03-11 11:11:09 -06:00
83f66e1061 feat: Support OAuth authentication flows for Claude 2026-03-11 11:10:48 -06:00
741b9c364c chore: Added support for Claude 4.6 gen models
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-10 14:55:30 -06:00
b6f6f456db fix: Removed top_p parameter from some agents so they can work across model providers
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-10 10:18:38 -06:00
00a6cf74d7 Merge branch 'main' of github.com:Dark-Alex-17/loki
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-09 14:58:23 -06:00
d35ca352ca chore: Added the new gemini-3.1-pro-preview model to gemini and vertex models
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-03-09 14:57:39 -06:00
57dc1cb252 docs: created an authorship policy and PR template that requires disclosure of AI assistance in contributions 2026-02-24 17:46:07 -07:00
101a9cdd6e style: Applied formatting to MCP module
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-20 15:28:21 -07:00
c5f52e1efb docs: Updated sisyphus README to always include the execute_command.sh tool
CI / All (macos-latest) (push) Has been cancelled
CI / All (ubuntu-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-02-20 15:06:57 -07:00
470149b606 docs: Updated the sisyphus system docs to have a pro-tip of configuring an IDE MCP server to improve performance 2026-02-20 15:01:08 -07:00
02062c5a50 docs: Created README docs for the CodeRabbit-style Code reviewer agents 2026-02-20 15:00:32 -07:00
e6e99b6926 feat: Improved MCP server spinup and spindown when switching contexts or settings in the REPL: Modify existing config rather than stopping all servers always and re-initializing if unnecessary 2026-02-20 14:36:34 -07:00
15a293204f fix: Improved sub-agent stdout and stderr output for users to follow 2026-02-20 13:47:28 -07:00
ecf3780aed Update models.yaml with latest OpenRouter data 2026-02-20 12:08:00 -07:00
e798747135 Add script to update models.yaml from OpenRouter 2026-02-20 12:07:59 -07:00
60493728a0 fix: Inject agent variables into environment variables for global tool calls when invoked from agents to modify global tool behavior 2026-02-20 11:38:24 -07:00
62 changed files with 2303 additions and 569 deletions
@@ -0,0 +1,11 @@
### AI assistance (if any):
- List tools here and files touched by them
### Authorship & Understanding
- [ ] I wrote or heavily modified this code myself
- [ ] I understand how it works end-to-end
- [ ] I can maintain this code in the future
- [ ] No undisclosed AI-generated code was used
- [ ] If AI assistance was used, it is documented below
+7
View File
@@ -76,6 +76,13 @@ Then, you can run workflows locally without having to commit and see if the GitH
act -W .github/workflows/release.yml --input_type bump=minor act -W .github/workflows/release.yml --input_type bump=minor
``` ```
## Authorship Policy
All code in this repository is written and reviewed by humans. AI-generated code (e.g., Copilot, ChatGPT,
Claude, etc.) is not permitted unless explicitly disclosed and approved.
Submissions must certify that the contributor understands and can maintain the code they submit.
## Questions? Reach out to me! ## Questions? Reach out to me!
If you encounter any questions while developing Loki, please don't hesitate to reach out to me at If you encounter any questions while developing Loki, please don't hesitate to reach out to me at
alex.j.tusa@gmail.com. I'm happy to help contributors in any way I can, regardless of if they're new or experienced! alex.j.tusa@gmail.com. I'm happy to help contributors in any way I can, regardless of if they're new or experienced!
Generated
+52 -70
View File
@@ -1238,7 +1238,7 @@ dependencies = [
"encode_unicode", "encode_unicode",
"libc", "libc",
"once_cell", "once_cell",
"unicode-width 0.2.2", "unicode-width",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1338,22 +1338,6 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
dependencies = [
"bitflags 1.3.2",
"crossterm_winapi",
"libc",
"mio 0.8.11",
"parking_lot",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.28.1" version = "0.28.1"
@@ -1363,7 +1347,7 @@ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"crossterm_winapi", "crossterm_winapi",
"filedescriptor", "filedescriptor",
"mio 1.1.1", "mio",
"parking_lot", "parking_lot",
"rustix 0.38.44", "rustix 0.38.44",
"serde", "serde",
@@ -1382,7 +1366,7 @@ dependencies = [
"crossterm_winapi", "crossterm_winapi",
"derive_more 2.1.1", "derive_more 2.1.1",
"document-features", "document-features",
"mio 1.1.1", "mio",
"parking_lot", "parking_lot",
"rustix 1.1.3", "rustix 1.1.3",
"signal-hook", "signal-hook",
@@ -2164,7 +2148,7 @@ version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [ dependencies = [
"unicode-width 0.2.2", "unicode-width",
] ]
[[package]] [[package]]
@@ -2838,19 +2822,16 @@ dependencies = [
[[package]] [[package]]
name = "inquire" name = "inquire"
version = "0.7.5" version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"crossterm 0.25.0", "crossterm 0.29.0",
"dyn-clone", "dyn-clone",
"fuzzy-matcher", "fuzzy-matcher",
"fxhash",
"newline-converter",
"once_cell",
"unicode-segmentation", "unicode-segmentation",
"unicode-width 0.1.14", "unicode-width",
] ]
[[package]] [[package]]
@@ -2869,6 +2850,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]] [[package]]
name = "is-macro" name = "is-macro"
version = "0.3.7" version = "0.3.7"
@@ -2892,6 +2882,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]] [[package]]
name = "is_executable" name = "is_executable"
version = "1.0.5" version = "1.0.5"
@@ -3193,6 +3193,7 @@ dependencies = [
"log4rs", "log4rs",
"nu-ansi-term", "nu-ansi-term",
"num_cpus", "num_cpus",
"open",
"os_info", "os_info",
"parking_lot", "parking_lot",
"path-absolutize", "path-absolutize",
@@ -3222,7 +3223,8 @@ dependencies = [
"tokio-graceful", "tokio-graceful",
"tokio-stream", "tokio-stream",
"unicode-segmentation", "unicode-segmentation",
"unicode-width 0.2.2", "unicode-width",
"url",
"urlencoding", "urlencoding",
"uuid", "uuid",
"which", "which",
@@ -3436,18 +3438,6 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.1.1"
@@ -3512,15 +3502,6 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "newline-converter"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f"
dependencies = [
"unicode-segmentation",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.26.4" version = "0.26.4"
@@ -3869,6 +3850,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.75" version = "0.10.75"
@@ -4039,6 +4031,12 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]] [[package]]
name = "pem" name = "pem"
version = "3.0.6" version = "3.0.6"
@@ -4517,7 +4515,7 @@ dependencies = [
"strum_macros 0.26.4", "strum_macros 0.26.4",
"thiserror 2.0.18", "thiserror 2.0.18",
"unicode-segmentation", "unicode-segmentation",
"unicode-width 0.2.2", "unicode-width",
] ]
[[package]] [[package]]
@@ -5339,8 +5337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [ dependencies = [
"libc", "libc",
"mio 0.8.11", "mio",
"mio 1.1.1",
"signal-hook", "signal-hook",
] ]
@@ -5644,7 +5641,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"memchr", "memchr",
"mio 1.1.1", "mio",
"terminal-trx", "terminal-trx",
"windows-sys 0.59.0", "windows-sys 0.59.0",
"xterm-color", "xterm-color",
@@ -5679,7 +5676,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [ dependencies = [
"smawk", "smawk",
"unicode-linebreak", "unicode-linebreak",
"unicode-width 0.2.2", "unicode-width",
] ]
[[package]] [[package]]
@@ -5824,7 +5821,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio 1.1.1", "mio",
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
@@ -6255,12 +6252,6 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.2.2" version = "0.2.2"
@@ -6928,15 +6919,6 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
+4 -2
View File
@@ -19,7 +19,7 @@ bytes = "1.4.0"
clap = { version = "4.5.40", features = ["cargo", "derive", "wrap_help"] } clap = { version = "4.5.40", features = ["cargo", "derive", "wrap_help"] }
dirs = "6.0.0" dirs = "6.0.0"
futures-util = "0.3.29" futures-util = "0.3.29"
inquire = "0.7.0" inquire = "0.9.4"
is-terminal = "0.4.9" is-terminal = "0.4.9"
reedline = "0.40.0" reedline = "0.40.0"
serde = { version = "1.0.152", features = ["derive"] } serde = { version = "1.0.152", features = ["derive"] }
@@ -96,6 +96,9 @@ colored = "3.0.0"
clap_complete = { version = "4.5.58", features = ["unstable-dynamic"] } clap_complete = { version = "4.5.58", features = ["unstable-dynamic"] }
gman = "0.3.0" gman = "0.3.0"
clap_complete_nushell = "4.5.9" clap_complete_nushell = "4.5.9"
open = "5"
rand = "0.9.0"
url = "2.5.8"
[dependencies.reqwest] [dependencies.reqwest]
version = "0.12.0" version = "0.12.0"
@@ -126,7 +129,6 @@ arboard = { version = "3.3.0", default-features = false }
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"
rand = "0.9.0"
[[bin]] [[bin]]
name = "loki" name = "loki"
+21
View File
@@ -39,6 +39,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [Todo System](./docs/TODO-SYSTEM.md): Built-in task tracking for improved agent reliability with smaller models. * [Todo System](./docs/TODO-SYSTEM.md): Built-in task tracking for improved agent reliability with smaller models.
* [Environment Variables](./docs/ENVIRONMENT-VARIABLES.md): Override and customize your Loki configuration at runtime with environment variables. * [Environment Variables](./docs/ENVIRONMENT-VARIABLES.md): Override and customize your Loki configuration at runtime with environment variables.
* [Client Configurations](./docs/clients/CLIENTS.md): Configuration instructions for various LLM providers. * [Client Configurations](./docs/clients/CLIENTS.md): Configuration instructions for various LLM providers.
* [Authentication (API Key & OAuth)](./docs/clients/CLIENTS.md#authentication): Authenticate with API keys or OAuth for subscription-based access.
* [Patching API Requests](./docs/clients/PATCHES.md): Learn how to patch API requests for advanced customization. * [Patching API Requests](./docs/clients/PATCHES.md): Learn how to patch API requests for advanced customization.
* [Custom Themes](./docs/THEMES.md): Change the look and feel of Loki to your preferences with custom themes. * [Custom Themes](./docs/THEMES.md): Change the look and feel of Loki to your preferences with custom themes.
* [History](#history): A history of how Loki came to be. * [History](#history): A history of how Loki came to be.
@@ -150,6 +151,26 @@ guide you through the process when you first attempt to access the vault. So, to
loki --list-secrets loki --list-secrets
``` ```
### Authentication
Each client in your configuration needs authentication (with a few exceptions; e.g. ollama). Most clients use an API key
(set via `api_key` in the config or through the [vault](./docs/VAULT.md)). For providers that support OAuth (e.g. Claude Pro/Max
subscribers, Google Gemini), you can authenticate with your existing subscription instead:
```yaml
# In your config.yaml
clients:
- type: claude
name: my-claude-oauth
auth: oauth # Indicate you want to authenticate with OAuth instead of an API key
```
```sh
loki --authenticate my-claude-oauth
# Or via the REPL: .authenticate
```
For full details, see the [authentication documentation](./docs/clients/CLIENTS.md#authentication).
### Tab-Completions ### Tab-Completions
You can also enable tab completions to make using Loki easier. To do so, add the following to your shell profile: You can also enable tab completions to make using Loki easier. To do so, add the following to your shell profile:
```shell ```shell
+1 -129
View File
@@ -2,68 +2,6 @@
# Shared Agent Utilities - Minimal, focused helper functions # Shared Agent Utilities - Minimal, focused helper functions
set -euo pipefail set -euo pipefail
#############################
## CONTEXT FILE MANAGEMENT ##
#############################
get_context_file() {
local project_dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
echo "${project_dir}/.loki-context"
}
# Initialize context file for a new task
# Usage: init_context "Task description"
init_context() {
local task="$1"
local project_dir="${LLM_AGENT_VAR_PROJECT_DIR:-.}"
local context_file
context_file=$(get_context_file)
cat > "${context_file}" <<EOF
## Project: ${project_dir}
## Task: ${task}
## Started: $(date -Iseconds)
### Prior Findings
EOF
}
# Append findings to the context file
# Usage: append_context "agent_name" "finding summary
append_context() {
local agent="$1"
local finding="$2"
local context_file
context_file=$(get_context_file)
if [[ -f "${context_file}" ]]; then
{
echo ""
echo "[${agent}]:"
echo "${finding}"
} >> "${context_file}"
fi
}
# Read the current context (returns empty string if no context)
# Usage: context=$(read_context)
read_context() {
local context_file
context_file=$(get_context_file)
if [[ -f "${context_file}" ]]; then
cat "${context_file}"
fi
}
# Clear the context file
clear_context() {
local context_file
context_file=$(get_context_file)
rm -f "${context_file}"
}
####################### #######################
## PROJECT DETECTION ## ## PROJECT DETECTION ##
####################### #######################
@@ -348,77 +286,11 @@ detect_project() {
echo '{"type":"unknown","build":"","test":"","check":""}' echo '{"type":"unknown","build":"","test":"","check":""}'
} }
######################
## AGENT INVOCATION ##
######################
# Invoke a subagent with optional context injection
# Usage: invoke_agent <agent_name> <prompt> [extra_args...]
invoke_agent() {
local agent="$1"
local prompt="$2"
shift 2
local context
context=$(read_context)
local full_prompt
if [[ -n "${context}" ]]; then
full_prompt="## Orchestrator Context
The orchestrator (sisyphus) has gathered this context from prior work:
<context>
${context}
</context>
## Your Task
${prompt}"
else
full_prompt="${prompt}"
fi
env AUTO_CONFIRM=true loki --agent "${agent}" "$@" "${full_prompt}" 2>&1
}
# Invoke a subagent and capture a summary of its findings
# Usage: result=$(invoke_agent_with_summary "explore" "find auth patterns")
invoke_agent_with_summary() {
local agent="$1"
local prompt="$2"
shift 2
local output
output=$(invoke_agent "${agent}" "${prompt}" "$@")
local summary=""
if echo "${output}" | grep -q "FINDINGS:"; then
summary=$(echo "${output}" | sed -n '/FINDINGS:/,/^[A-Z_]*COMPLETE/p' | grep "^- " | sed 's/^- / - /')
elif echo "${output}" | grep -q "CODER_COMPLETE:"; then
summary=$(echo "${output}" | grep "CODER_COMPLETE:" | sed 's/CODER_COMPLETE: *//')
elif echo "${output}" | grep -q "ORACLE_COMPLETE"; then
summary=$(echo "${output}" | sed -n '/^## Recommendation/,/^## /{/^## Recommendation/d;/^## /d;p}' | sed '/^$/d' | head -10)
fi
# Failsafe: extract up to 5 meaningful lines if no markers found
if [[ -z "${summary}" ]]; then
summary=$(echo "${output}" | grep -v "^$" | grep -v "^#" | grep -v "^\-\-\-" | tail -10 | head -5)
fi
if [[ -n "${summary}" ]]; then
append_context "${agent}" "${summary}"
fi
echo "${output}"
}
########################### ###########################
## FILE SEARCH UTILITIES ## ## FILE SEARCH UTILITIES ##
########################### ###########################
search_files() { _search_files() {
local pattern="$1" local pattern="$1"
local dir="${2:-.}" local dir="${2:-.}"
+36
View File
@@ -0,0 +1,36 @@
# Code Reviewer
A CodeRabbit-style code review orchestrator that coordinates per-file reviews and synthesizes findings into a unified
report.
This agent acts as the manager for the review process, delegating actual file analysis to **[File Reviewer](../file-reviewer/README.md)**
agents while handling coordination and final reporting.
## Features
- 🤖 **Orchestration**: Spawns parallel reviewers for each changed file.
- 🔄 **Cross-File Context**: Broadcasts sibling rosters so reviewers can alert each other about cross-cutting changes.
- 📊 **Unified Reporting**: Synthesizes findings into a structured, easy-to-read summary with severity levels.
-**Parallel Execution**: Runs reviews concurrently for maximum speed.
## Pro-Tip: Use an IDE MCP Server for Improved Performance
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
them), and modify the agent definition to look like this:
```yaml
# ...
mcp_servers:
- jetbrains # The name of your configured IDE MCP server
global_tools:
- fs_read.sh
- fs_grep.sh
- fs_glob.sh
# - execute_command.sh
# ...
```
+3 -1
View File
@@ -2,7 +2,6 @@ name: code-reviewer
description: CodeRabbit-style code reviewer - spawns per-file reviewers, synthesizes findings description: CodeRabbit-style code reviewer - spawns per-file reviewers, synthesizes findings
version: 1.0.0 version: 1.0.0
temperature: 0.1 temperature: 0.1
top_p: 0.95
auto_continue: true auto_continue: true
max_auto_continues: 20 max_auto_continues: 20
@@ -123,3 +122,6 @@ instructions: |
- Project: {{project_dir}} - Project: {{project_dir}}
- CWD: {{__cwd__}} - CWD: {{__cwd__}}
- Shell: {{__shell__}} - Shell: {{__shell__}}
## Available Tools:
{{__tools__}}
+24
View File
@@ -14,3 +14,27 @@ acts as the coordinator/architect, while Coder handles the implementation detail
- 📊 Precise diff-based file editing for controlled code modifications - 📊 Precise diff-based file editing for controlled code modifications
It can also be used as a standalone tool for direct coding assistance. It can also be used as a standalone tool for direct coding assistance.
## Pro-Tip: Use an IDE MCP Server for Improved Performance
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
them), and modify the agent definition to look like this:
```yaml
# ...
mcp_servers:
- jetbrains # The name of your configured IDE MCP server
global_tools:
# Keep useful read-only tools for reading files in other non-project directories
- fs_read.sh
- fs_grep.sh
- fs_glob.sh
# - fs_write.sh
# - fs_patch.sh
- execute_command.sh
# ...
```
+28 -7
View File
@@ -2,7 +2,6 @@ name: coder
description: Implementation agent - writes code, follows patterns, verifies with builds description: Implementation agent - writes code, follows patterns, verifies with builds
version: 1.0.0 version: 1.0.0
temperature: 0.1 temperature: 0.1
top_p: 0.95
auto_continue: true auto_continue: true
max_auto_continues: 15 max_auto_continues: 15
@@ -30,11 +29,30 @@ instructions: |
## Your Mission ## Your Mission
Given an implementation task: Given an implementation task:
1. Understand what to build (from context provided) 1. Check for orchestrator context first (see below)
2. Study existing patterns (read 1-2 similar files) 2. Fill gaps only. Read files NOT already covered in context
3. Write the code (using tools, NOT chat output) 3. Write the code (using tools, NOT chat output)
4. Verify it compiles/builds 4. Verify it compiles/builds
5. Signal completion 5. Signal completion with a summary
## Using Orchestrator Context (IMPORTANT)
When spawned by sisyphus, your prompt will often contain a `<context>` block
with prior findings: file paths, code patterns, and conventions discovered by
explore agents.
**If context is provided:**
1. Use it as your primary reference. Don't re-read files already summarized
2. Follow the code patterns shown. Snippets in context ARE the style guide
3. Read the referenced files ONLY IF you need more detail (e.g. full function
signature, import list, or adjacent code not included in the snippet)
4. If context includes a "Conventions" section, follow it exactly
**If context is NOT provided or is too vague to act on:**
Fall back to self-exploration: grep for similar files, read 1-2 examples,
match their style.
**Never ignore provided context.** It represents work already done upstream.
## Todo System ## Todo System
@@ -83,12 +101,13 @@ instructions: |
## Completion Signal ## Completion Signal
End with: When done, end your response with a summary so the parent agent knows what happened:
``` ```
CODER_COMPLETE: [summary of what was implemented] CODER_COMPLETE: [summary of what was implemented, which files were created/modified, and build status]
``` ```
Or if failed: Or if something went wrong:
``` ```
CODER_FAILED: [what went wrong] CODER_FAILED: [what went wrong]
``` ```
@@ -106,3 +125,5 @@ instructions: |
- CWD: {{__cwd__}} - CWD: {{__cwd__}}
- Shell: {{__shell__}} - Shell: {{__shell__}}
## Available tools:
{{__tools__}}
+26 -6
View File
@@ -14,11 +14,28 @@ _project_dir() {
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}" (cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
} }
# Normalize a path to be relative to project root.
# Strips the project_dir prefix if the LLM passes an absolute path.
# Usage: local rel_path; rel_path=$(_normalize_path "/abs/or/rel/path")
_normalize_path() {
local input_path="$1"
local project_dir
project_dir=$(_project_dir)
if [[ "${input_path}" == /* ]]; then
input_path="${input_path#"${project_dir}"/}"
fi
input_path="${input_path#./}"
echo "${input_path}"
}
# @cmd Read a file's contents before modifying # @cmd Read a file's contents before modifying
# @option --path! Path to the file (relative to project root) # @option --path! Path to the file (relative to project root)
read_file() { read_file() {
local file_path
# shellcheck disable=SC2154 # shellcheck disable=SC2154
local file_path="${argc_path}" file_path=$(_normalize_path "${argc_path}")
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
local full_path="${project_dir}/${file_path}" local full_path="${project_dir}/${file_path}"
@@ -39,7 +56,8 @@ read_file() {
# @option --path! Path for the file (relative to project root) # @option --path! Path for the file (relative to project root)
# @option --content! Complete file contents to write # @option --content! Complete file contents to write
write_file() { write_file() {
local file_path="${argc_path}" local file_path
file_path=$(_normalize_path "${argc_path}")
# shellcheck disable=SC2154 # shellcheck disable=SC2154
local content="${argc_content}" local content="${argc_content}"
local project_dir local project_dir
@@ -47,7 +65,7 @@ write_file() {
local full_path="${project_dir}/${file_path}" local full_path="${project_dir}/${file_path}"
mkdir -p "$(dirname "${full_path}")" mkdir -p "$(dirname "${full_path}")"
echo "${content}" > "${full_path}" printf '%s' "${content}" > "${full_path}"
green "Wrote: ${file_path}" >> "$LLM_OUTPUT" green "Wrote: ${file_path}" >> "$LLM_OUTPUT"
} }
@@ -55,7 +73,8 @@ write_file() {
# @cmd Find files similar to a given path (for pattern matching) # @cmd Find files similar to a given path (for pattern matching)
# @option --path! Path to find similar files for # @option --path! Path to find similar files for
find_similar_files() { find_similar_files() {
local file_path="${argc_path}" local file_path
file_path=$(_normalize_path "${argc_path}")
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
@@ -71,14 +90,14 @@ find_similar_files() {
! -name "$(basename "${file_path}")" \ ! -name "$(basename "${file_path}")" \
! -name "*test*" \ ! -name "*test*" \
! -name "*spec*" \ ! -name "*spec*" \
2>/dev/null | head -3) 2>/dev/null | sed "s|^${project_dir}/||" | head -3)
if [[ -z "${results}" ]]; then if [[ -z "${results}" ]]; then
results=$(find "${project_dir}/src" -type f -name "*.${ext}" \ results=$(find "${project_dir}/src" -type f -name "*.${ext}" \
! -name "*test*" \ ! -name "*test*" \
! -name "*spec*" \ ! -name "*spec*" \
-not -path '*/target/*' \ -not -path '*/target/*' \
2>/dev/null | head -3) 2>/dev/null | sed "s|^${project_dir}/||" | head -3)
fi fi
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
@@ -186,6 +205,7 @@ search_code() {
grep -v '/target/' | \ grep -v '/target/' | \
grep -v '/node_modules/' | \ grep -v '/node_modules/' | \
grep -v '/.git/' | \ grep -v '/.git/' | \
sed "s|^${project_dir}/||" | \
head -20) || true head -20) || true
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
+22
View File
@@ -13,3 +13,25 @@ It can also be used as a standalone tool for understanding codebases and finding
- 📂 File system navigation and content analysis - 📂 File system navigation and content analysis
- 🧠 Context gathering for complex tasks - 🧠 Context gathering for complex tasks
- 🛡️ Read-only operations for safe investigation - 🛡️ Read-only operations for safe investigation
## Pro-Tip: Use an IDE MCP Server for Improved Performance
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
them), and modify the agent definition to look like this:
```yaml
# ...
mcp_servers:
- jetbrains # The name of your configured IDE MCP server
global_tools:
- fs_read.sh
- fs_grep.sh
- fs_glob.sh
- fs_ls.sh
- web_search_loki.sh
# ...
```
+5 -2
View File
@@ -2,19 +2,19 @@ name: explore
description: Fast codebase exploration agent - finds patterns, structures, and relevant files description: Fast codebase exploration agent - finds patterns, structures, and relevant files
version: 1.0.0 version: 1.0.0
temperature: 0.1 temperature: 0.1
top_p: 0.95
variables: variables:
- name: project_dir - name: project_dir
description: Project directory to explore description: Project directory to explore
default: '.' default: '.'
mcp_servers:
- ddg-search
global_tools: global_tools:
- fs_read.sh - fs_read.sh
- fs_grep.sh - fs_grep.sh
- fs_glob.sh - fs_glob.sh
- fs_ls.sh - fs_ls.sh
- web_search_loki.sh
instructions: | instructions: |
You are a codebase explorer. Your job: Search, find, report. Nothing else. You are a codebase explorer. Your job: Search, find, report. Nothing else.
@@ -69,6 +69,9 @@ instructions: |
- Project: {{project_dir}} - Project: {{project_dir}}
- CWD: {{__cwd__}} - CWD: {{__cwd__}}
## Available Tools:
{{__tools__}}
conversation_starters: conversation_starters:
- 'Find how authentication is implemented' - 'Find how authentication is implemented'
- 'What patterns are used for API endpoints' - 'What patterns are used for API endpoints'
+23 -5
View File
@@ -14,6 +14,21 @@ _project_dir() {
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}" (cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
} }
# Normalize a path to be relative to project root.
# Strips the project_dir prefix if the LLM passes an absolute path.
_normalize_path() {
local input_path="$1"
local project_dir
project_dir=$(_project_dir)
if [[ "${input_path}" == /* ]]; then
input_path="${input_path#"${project_dir}"/}"
fi
input_path="${input_path#./}"
echo "${input_path}"
}
# @cmd Get project structure and layout # @cmd Get project structure and layout
get_structure() { get_structure() {
local project_dir local project_dir
@@ -45,7 +60,7 @@ search_files() {
echo "" >> "$LLM_OUTPUT" echo "" >> "$LLM_OUTPUT"
local results local results
results=$(search_files "${pattern}" "${project_dir}") results=$(_search_files "${pattern}" "${project_dir}")
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT" echo "${results}" >> "$LLM_OUTPUT"
@@ -78,6 +93,7 @@ search_content() {
grep -v '/node_modules/' | \ grep -v '/node_modules/' | \
grep -v '/.git/' | \ grep -v '/.git/' | \
grep -v '/dist/' | \ grep -v '/dist/' | \
sed "s|^${project_dir}/||" | \
head -30) || true head -30) || true
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
@@ -91,8 +107,9 @@ search_content() {
# @option --path! Path to the file (relative to project root) # @option --path! Path to the file (relative to project root)
# @option --lines Maximum lines to read (default: 200) # @option --lines Maximum lines to read (default: 200)
read_file() { read_file() {
local file_path
# shellcheck disable=SC2154 # shellcheck disable=SC2154
local file_path="${argc_path}" file_path=$(_normalize_path "${argc_path}")
local max_lines="${argc_lines:-200}" local max_lines="${argc_lines:-200}"
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
@@ -122,7 +139,8 @@ read_file() {
# @cmd Find similar files to a given file (for pattern matching) # @cmd Find similar files to a given file (for pattern matching)
# @option --path! Path to the reference file # @option --path! Path to the reference file
find_similar() { find_similar() {
local file_path="${argc_path}" local file_path
file_path=$(_normalize_path "${argc_path}")
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
@@ -138,7 +156,7 @@ find_similar() {
! -name "$(basename "${file_path}")" \ ! -name "$(basename "${file_path}")" \
! -name "*test*" \ ! -name "*test*" \
! -name "*spec*" \ ! -name "*spec*" \
2>/dev/null | head -5) 2>/dev/null | sed "s|^${project_dir}/||" | head -5)
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT" echo "${results}" >> "$LLM_OUTPUT"
@@ -147,7 +165,7 @@ find_similar() {
! -name "$(basename "${file_path}")" \ ! -name "$(basename "${file_path}")" \
! -name "*test*" \ ! -name "*test*" \
-not -path '*/target/*' \ -not -path '*/target/*' \
2>/dev/null | head -5) 2>/dev/null | sed "s|^${project_dir}/||" | head -5)
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT" echo "${results}" >> "$LLM_OUTPUT"
else else
+35
View File
@@ -0,0 +1,35 @@
# File Reviewer
A specialized worker agent that reviews a single file's diff for bugs, style issues, and cross-cutting concerns.
This agent is designed to be spawned by the **[Code Reviewer](../code-reviewer/README.md)** agent. It focuses deeply on
one file while communicating with sibling agents to catch issues that span multiple files.
## Features
- 🔍 **Deep Analysis**: Focuses on bugs, logic errors, security issues, and style problems in a single file.
- 🗣️ **Teammate Communication**: Sends and receives alerts to/from sibling reviewers about interface or dependency
changes.
- 🎯 **Targeted Reading**: Reads only relevant context around changed lines to stay efficient.
- 🏷️ **Structured Findings**: Categorizes issues by severity (🔴 Critical, 🟡 Warning, 🟢 Suggestion, 💡 Nitpick).
## Pro-Tip: Use an IDE MCP Server for Improved Performance
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
them), and modify the agent definition to look like this:
```yaml
# ...
mcp_servers:
- jetbrains # The name of your configured IDE MCP server
global_tools:
- fs_read.sh
- fs_grep.sh
- fs_glob.sh
# ...
```
+3 -1
View File
@@ -2,7 +2,6 @@ name: file-reviewer
description: Reviews a single file's diff for bugs, style issues, and cross-cutting concerns description: Reviews a single file's diff for bugs, style issues, and cross-cutting concerns
version: 1.0.0 version: 1.0.0
temperature: 0.1 temperature: 0.1
top_p: 0.95
variables: variables:
- name: project_dir - name: project_dir
@@ -109,3 +108,6 @@ instructions: |
## Context ## Context
- Project: {{project_dir}} - Project: {{project_dir}}
- CWD: {{__cwd__}} - CWD: {{__cwd__}}
## Available Tools:
{{__tools__}}
+22
View File
@@ -15,3 +15,25 @@ It can also be used as a standalone tool for design reviews and solving difficul
- ⚖️ Tradeoff analysis and technology selection - ⚖️ Tradeoff analysis and technology selection
- 📝 Code review and best practices advice - 📝 Code review and best practices advice
- 🧠 Deep reasoning for ambiguous problems - 🧠 Deep reasoning for ambiguous problems
## Pro-Tip: Use an IDE MCP Server for Improved Performance
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
them), and modify the agent definition to look like this:
```yaml
# ...
mcp_servers:
- jetbrains # The name of your configured IDE MCP server
global_tools:
- fs_read.sh
- fs_grep.sh
- fs_glob.sh
- fs_ls.sh
- web_search_loki.sh
# ...
```
+5 -2
View File
@@ -2,19 +2,19 @@ name: oracle
description: High-IQ advisor for architecture, debugging, and complex decisions description: High-IQ advisor for architecture, debugging, and complex decisions
version: 1.0.0 version: 1.0.0
temperature: 0.2 temperature: 0.2
top_p: 0.95
variables: variables:
- name: project_dir - name: project_dir
description: Project directory for context description: Project directory for context
default: '.' default: '.'
mcp_servers:
- ddg-search
global_tools: global_tools:
- fs_read.sh - fs_read.sh
- fs_grep.sh - fs_grep.sh
- fs_glob.sh - fs_glob.sh
- fs_ls.sh - fs_ls.sh
- web_search_loki.sh
instructions: | instructions: |
You are Oracle - a senior architect and debugger consulted for complex decisions. You are Oracle - a senior architect and debugger consulted for complex decisions.
@@ -76,6 +76,9 @@ instructions: |
- Project: {{project_dir}} - Project: {{project_dir}}
- CWD: {{__cwd__}} - CWD: {{__cwd__}}
## Available Tools:
{{__tools__}}
conversation_starters: conversation_starters:
- 'Review this architecture design' - 'Review this architecture design'
- 'Help debug this complex issue' - 'Help debug this complex issue'
+23 -4
View File
@@ -14,21 +14,38 @@ _project_dir() {
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}" (cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
} }
# Normalize a path to be relative to project root.
# Strips the project_dir prefix if the LLM passes an absolute path.
_normalize_path() {
local input_path="$1"
local project_dir
project_dir=$(_project_dir)
if [[ "${input_path}" == /* ]]; then
input_path="${input_path#"${project_dir}"/}"
fi
input_path="${input_path#./}"
echo "${input_path}"
}
# @cmd Read a file for analysis # @cmd Read a file for analysis
# @option --path! Path to the file (relative to project root) # @option --path! Path to the file (relative to project root)
read_file() { read_file() {
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
local file_path
# shellcheck disable=SC2154 # shellcheck disable=SC2154
local full_path="${project_dir}/${argc_path}" file_path=$(_normalize_path "${argc_path}")
local full_path="${project_dir}/${file_path}"
if [[ ! -f "${full_path}" ]]; then if [[ ! -f "${full_path}" ]]; then
error "File not found: ${argc_path}" >> "$LLM_OUTPUT" error "File not found: ${file_path}" >> "$LLM_OUTPUT"
return 1 return 1
fi fi
{ {
info "Reading: ${argc_path}" info "Reading: ${file_path}"
echo "" echo ""
cat "${full_path}" cat "${full_path}"
} >> "$LLM_OUTPUT" } >> "$LLM_OUTPUT"
@@ -80,6 +97,7 @@ search_code() {
grep -v '/target/' | \ grep -v '/target/' | \
grep -v '/node_modules/' | \ grep -v '/node_modules/' | \
grep -v '/.git/' | \ grep -v '/.git/' | \
sed "s|^${project_dir}/||" | \
head -30) || true head -30) || true
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
@@ -113,7 +131,8 @@ analyze_with_command() {
# @cmd List directory contents # @cmd List directory contents
# @option --path Path to list (default: project root) # @option --path Path to list (default: project root)
list_directory() { list_directory() {
local dir_path="${argc_path:-.}" local dir_path
dir_path=$(_normalize_path "${argc_path:-.}")
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
local full_path="${project_dir}/${dir_path}" local full_path="${project_dir}/${dir_path}"
+23
View File
@@ -16,3 +16,26 @@ Sisyphus acts as the primary entry point, capable of handling complex tasks by c
- 💻 **CLI Coding**: Provides a natural language interface for writing and editing code. - 💻 **CLI Coding**: Provides a natural language interface for writing and editing code.
- 🔄 **Task Management**: Tracks progress and context across complex operations. - 🔄 **Task Management**: Tracks progress and context across complex operations.
- 🛠️ **Tool Integration**: Seamlessly uses system tools for building, testing, and file manipulation. - 🛠️ **Tool Integration**: Seamlessly uses system tools for building, testing, and file manipulation.
## Pro-Tip: Use an IDE MCP Server for Improved Performance
Many modern IDEs now include MCP servers that let LLMs perform operations within the IDE itself and use IDE tools. Using
an IDE's MCP server dramatically improves the performance of coding agents. So if you have an IDE, try adding that MCP
server to your config (see the [MCP Server docs](../../../docs/function-calling/MCP-SERVERS.md) to see how to configure
them), and modify the agent definition to look like this:
```yaml
# ...
mcp_servers:
- jetbrains
global_tools:
- fs_read.sh
- fs_grep.sh
- fs_glob.sh
- fs_ls.sh
- web_search_loki.sh
- execute_command.sh
# ...
```
+45 -7
View File
@@ -2,7 +2,6 @@ name: sisyphus
description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos
version: 2.0.0 version: 2.0.0
temperature: 0.1 temperature: 0.1
top_p: 0.95
agent_session: temp agent_session: temp
auto_continue: true auto_continue: true
@@ -13,7 +12,7 @@ can_spawn_agents: true
max_concurrent_agents: 4 max_concurrent_agents: 4
max_agent_depth: 3 max_agent_depth: 3
inject_spawn_instructions: true inject_spawn_instructions: true
summarization_threshold: 4000 summarization_threshold: 8000
variables: variables:
- name: project_dir - name: project_dir
@@ -23,12 +22,13 @@ variables:
description: Auto-confirm command execution description: Auto-confirm command execution
default: '1' default: '1'
mcp_servers:
- ddg-search
global_tools: global_tools:
- fs_read.sh - fs_read.sh
- fs_grep.sh - fs_grep.sh
- fs_glob.sh - fs_glob.sh
- fs_ls.sh - fs_ls.sh
- web_search_loki.sh
- execute_command.sh - execute_command.sh
instructions: | instructions: |
@@ -70,6 +70,45 @@ instructions: |
| coder | Write/edit files, implement features | Creates/modifies files, runs builds | | coder | Write/edit files, implement features | Creates/modifies files, runs builds |
| oracle | Architecture decisions, complex debugging | Advisory, high-quality reasoning | | oracle | Architecture decisions, complex debugging | Advisory, high-quality reasoning |
## Coder Delegation Format (MANDATORY)
When spawning the `coder` agent, your prompt MUST include these sections.
The coder has NOT seen the codebase. Your prompt IS its entire context.
### Template:
```
## Goal
[1-2 sentences: what to build/modify and where]
## Reference Files
[Files that explore found, with what each demonstrates]
- `path/to/file.ext` - what pattern this file shows
- `path/to/other.ext` - what convention this file shows
## Code Patterns to Follow
[Paste ACTUAL code snippets from explore results, not descriptions]
<code>
// From path/to/file.ext - this is the pattern to follow:
[actual code explore found, 5-20 lines]
</code>
## Conventions
[Naming, imports, error handling, file organization]
- Convention 1
- Convention 2
## Constraints
[What NOT to do, scope boundaries]
- Do NOT modify X
- Only touch files in Y/
```
**CRITICAL**: Include actual code snippets, not just file paths.
If explore returned code patterns, paste them into the coder prompt.
Vague prompts like "follow existing patterns" waste coder's tokens on
re-exploration that you already did.
## Workflow Examples ## Workflow Examples
### Example 1: Implementation task (explore -> coder, parallel exploration) ### Example 1: Implementation task (explore -> coder, parallel exploration)
@@ -81,12 +120,12 @@ instructions: |
2. todo__add --task "Explore existing API patterns" 2. todo__add --task "Explore existing API patterns"
3. todo__add --task "Implement profile endpoint" 3. todo__add --task "Implement profile endpoint"
4. todo__add --task "Verify with build/test" 4. todo__add --task "Verify with build/test"
5. agent__spawn --agent explore --prompt "Find existing API endpoint patterns, route structures, and controller conventions" 5. agent__spawn --agent explore --prompt "Find existing API endpoint patterns, route structures, and controller conventions. Include code snippets."
6. agent__spawn --agent explore --prompt "Find existing data models and database query patterns" 6. agent__spawn --agent explore --prompt "Find existing data models and database query patterns. Include code snippets."
7. agent__collect --id <id1> 7. agent__collect --id <id1>
8. agent__collect --id <id2> 8. agent__collect --id <id2>
9. todo__done --id 1 9. todo__done --id 1
10. agent__spawn --agent coder --prompt "Create user profiles endpoint following existing patterns. [Include context from explore results]" 10. agent__spawn --agent coder --prompt "<structured prompt using Coder Delegation Format above, including code snippets from explore results>"
11. agent__collect --id <coder_id> 11. agent__collect --id <coder_id>
12. todo__done --id 2 12. todo__done --id 2
13. run_build 13. run_build
@@ -135,7 +174,6 @@ instructions: |
## When to Do It Yourself ## When to Do It Yourself
- Single-file reads/writes
- Simple command execution - Simple command execution
- Trivial changes (typos, renames) - Trivial changes (typos, renames)
- Quick file searches - Quick file searches
+5 -1
View File
@@ -16,11 +16,15 @@
}, },
"atlassian": { "atlassian": {
"command": "npx", "command": "npx",
"args": ["-y", "mcp-remote@0.1.13", "https://mcp.atlassian.com/v1/sse"] "args": ["-y", "mcp-remote@0.1.13", "https://mcp.atlassian.com/v1/mcp"]
}, },
"docker": { "docker": {
"command": "uvx", "command": "uvx",
"args": ["mcp-server-docker"] "args": ["mcp-server-docker"]
},
"ddg-search": {
"command": "uvx",
"args": ["duckduckgo-mcp-server"]
} }
} }
} }
+1 -1
View File
@@ -32,7 +32,7 @@ max_concurrent_agents: 4 # Maximum number of agents that can run simulta
max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning) max_agent_depth: 3 # Maximum nesting depth for sub-agents (prevents runaway spawning)
inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt inject_spawn_instructions: true # Inject the default agent spawning instructions into the agent's system prompt
summarization_model: null # Model to use for summarizing sub-agent output (e.g. 'openai:gpt-4o-mini'); defaults to current model summarization_model: null # Model to use for summarizing sub-agent output (e.g. 'openai:gpt-4o-mini'); defaults to current model
summarization_threshold: 4000 # Character threshold above which sub-agent output is summarized before returning to parent summarization_threshold: 4000 # Character threshold above which sub-agent output is summarized before returning to parent
escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes) escalation_timeout: 300 # Seconds a sub-agent waits for a user interaction response before timing out (default: 5 minutes)
mcp_servers: # Optional list of MCP servers that the agent utilizes mcp_servers: # Optional list of MCP servers that the agent utilizes
- github # Corresponds to the name of an MCP server in the `<loki-config-dir>/functions/mcp.json` file - github # Corresponds to the name of an MCP server in the `<loki-config-dir>/functions/mcp.json` file
+5 -1
View File
@@ -77,7 +77,7 @@ visible_tools: # Which tools are visible to be compiled (and a
mcp_server_support: true # Enables or disables MCP servers (globally). mcp_server_support: true # Enables or disables MCP servers (globally).
mapping_mcp_servers: # Alias for an MCP server or set of servers mapping_mcp_servers: # Alias for an MCP server or set of servers
git: github,gitmcp git: github,gitmcp
enabled_mcp_servers: null # Which MCP servers to enable by default (e.g. 'github,slack') enabled_mcp_servers: null # Which MCP servers to enable by default (e.g. 'github,slack,ddg-search')
# ---- Session ---- # ---- Session ----
# See the [Session documentation](./docs/SESSIONS.md) for more information # See the [Session documentation](./docs/SESSIONS.md) for more information
@@ -192,6 +192,8 @@ clients:
- type: gemini - type: gemini
api_base: https://generativelanguage.googleapis.com/v1beta api_base: https://generativelanguage.googleapis.com/v1beta
api_key: '{{GEMINI_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault api_key: '{{GEMINI_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
auth: null # When set to 'oauth', Loki will use OAuth instead of an API key
# Authenticate with `loki --authenticate` or `.authenticate` in the REPL
patch: patch:
chat_completions: chat_completions:
'.*': '.*':
@@ -210,6 +212,8 @@ clients:
- type: claude - type: claude
api_base: https://api.anthropic.com/v1 # Optional api_base: https://api.anthropic.com/v1 # Optional
api_key: '{{ANTHROPIC_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault api_key: '{{ANTHROPIC_API_KEY}}' # You can either hard-code or inject secrets from the Loki vault
auth: null # When set to 'oauth', Loki will use OAuth instead of an API key
# Authenticate with `loki --authenticate` or `.authenticate` in the REPL
# See https://docs.mistral.ai/ # See https://docs.mistral.ai/
- type: openai-compatible - type: openai-compatible
+3 -1
View File
@@ -467,11 +467,12 @@ inject_todo_instructions: true # Include the default todo instructions into pr
### How It Works ### How It Works
1. When `inject_todo_instructions` is enabled, agents receive instructions on using four built-in tools: 1. When `inject_todo_instructions` is enabled, agents receive instructions on using five built-in tools:
- `todo__init`: Initialize a todo list with a goal - `todo__init`: Initialize a todo list with a goal
- `todo__add`: Add a task to the list - `todo__add`: Add a task to the list
- `todo__done`: Mark a task complete - `todo__done`: Mark a task complete
- `todo__list`: View current todo state - `todo__list`: View current todo state
- `todo__clear`: Clear the entire todo list and reset the goal
These instructions are a reasonable default that detail how to use Loki's To-Do System. If you wish, These instructions are a reasonable default that detail how to use Loki's To-Do System. If you wish,
you can disable the injection of the default instructions and specify your own instructions for how you can disable the injection of the default instructions and specify your own instructions for how
@@ -714,6 +715,7 @@ Loki comes packaged with some useful built-in agents:
* `code-reviewer`: A [CodeRabbit](https://coderabbit.ai)-style code reviewer that spawns per-file reviewers using the teammate messaging pattern * `code-reviewer`: A [CodeRabbit](https://coderabbit.ai)-style code reviewer that spawns per-file reviewers using the teammate messaging pattern
* `demo`: An example agent to use for reference when learning to create your own agents * `demo`: An example agent to use for reference when learning to create your own agents
* `explore`: An agent designed to help you explore and understand your codebase * `explore`: An agent designed to help you explore and understand your codebase
* `file-reviewer`: An agent designed to perform code-review on a single file (used by the `code-reviewer` agent)
* `jira-helper`: An agent that assists you with all your Jira-related tasks * `jira-helper`: An agent that assists you with all your Jira-related tasks
* `oracle`: An agent for high-level architecture, design decisions, and complex debugging * `oracle`: An agent for high-level architecture, design decisions, and complex debugging
* `sisyphus`: A powerhouse orchestrator agent for writing complex code and acting as a natural language interface for your codebase (similar to ClaudeCode, Gemini CLI, Codex, or OpenCode). Uses sub-agent spawning to delegate to `explore`, `coder`, and `oracle`. * `sisyphus`: A powerhouse orchestrator agent for writing complex code and acting as a natural language interface for your codebase (similar to ClaudeCode, Gemini CLI, Codex, or OpenCode). Uses sub-agent spawning to delegate to `explore`, `coder`, and `oracle`.
+14 -7
View File
@@ -23,6 +23,7 @@ You can enter the REPL by simply typing `loki` without any follow-up flags or ar
- [`.edit` - Modify configuration files](#edit---modify-configuration-files) - [`.edit` - Modify configuration files](#edit---modify-configuration-files)
- [`.delete` - Delete configurations from Loki](#delete---delete-configurations-from-loki) - [`.delete` - Delete configurations from Loki](#delete---delete-configurations-from-loki)
- [`.info` - Display information about the current mode](#info---display-information-about-the-current-mode) - [`.info` - Display information about the current mode](#info---display-information-about-the-current-mode)
- [`.authenticate` - Authenticate the current model client via OAuth](#authenticate---authenticate-the-current-model-client-via-oauth)
- [`.exit` - Exit an agent/role/session/rag or the Loki REPL itself](#exit---exit-an-agentrolesessionrag-or-the-loki-repl-itself) - [`.exit` - Exit an agent/role/session/rag or the Loki REPL itself](#exit---exit-an-agentrolesessionrag-or-the-loki-repl-itself)
- [`.help` - Show the help guide](#help---show-the-help-guide) - [`.help` - Show the help guide](#help---show-the-help-guide)
<!--toc:end--> <!--toc:end-->
@@ -119,13 +120,14 @@ For more information on sessions and how to use them in Loki, refer to the [sess
Loki lets you build OpenAI GPT-style agents. The following commands let you interact with and manage your agents in Loki lets you build OpenAI GPT-style agents. The following commands let you interact with and manage your agents in
Loki: Loki:
| Command | Description | | Command | Description |
|----------------------|------------------------------------------------------------| |----------------------|-----------------------------------------------------------------------------------------------|
| `.agent` | Use an agent | | `.agent` | Use an agent |
| `.starter` | Display and use conversation starters for the active agent | | `.starter` | Display and use conversation starters for the active agent |
| `.edit agent-config` | Open the agent configuration in your preferred text editor | | `.clear todo` | Clear the todo list and stop auto-continuation (requires `auto_continue: true` on the agent) |
| `.info agent` | Display information about the active agent | | `.edit agent-config` | Open the agent configuration in your preferred text editor |
| `.exit agent` | Leave the active agent | | `.info agent` | Display information about the active agent |
| `.exit agent` | Leave the active agent |
![agent](./images/agents/sql.gif) ![agent](./images/agents/sql.gif)
@@ -237,6 +239,11 @@ The following entities are supported:
| `.info agent` | Display information about the active agent | | `.info agent` | Display information about the active agent |
| `.info rag` | Display information about the active RAG | | `.info rag` | Display information about the active RAG |
### `.authenticate` - Authenticate the current model client via OAuth
The `.authenticate` command will start the OAuth flow for the current model client if
* The client supports OAuth (See the [clients documentation](./clients/CLIENTS.md#providers-that-support-oauth) for supported clients)
* The client is configured in your Loki configuration to use OAuth via the `auth: oauth` property
### `.exit` - Exit an agent/role/session/rag or the Loki REPL itself ### `.exit` - Exit an agent/role/session/rag or the Loki REPL itself
The `.exit` command is used to move between modes in the Loki REPL. The `.exit` command is used to move between modes in the Loki REPL.
+16
View File
@@ -117,6 +117,22 @@ Display the current todo list with status of each item.
**Returns:** The full todo list with goal, progress, and item statuses **Returns:** The full todo list with goal, progress, and item statuses
### `todo__clear`
Clear the entire todo list and reset the goal. Use when the current task has been canceled or invalidated.
**Parameters:** None
**Returns:** Confirmation that the todo list was cleared
### REPL Command: `.clear todo`
You can also clear the todo list manually from the REPL by typing `.clear todo`. This is useful when:
- You gave a custom response that changes or cancels the current task
- The agent is stuck in auto-continuation with stale todos
- You want to start fresh without leaving and re-entering the agent
**Note:** This command is only available when an agent with `auto_continue: true` is active. If the todo
system isn't enabled for the current agent, the command will display an error message.
## Auto-Continuation ## Auto-Continuation
When `auto_continue` is enabled, Loki automatically sends a continuation prompt if: When `auto_continue` is enabled, Loki automatically sends a continuation prompt if:
+99 -6
View File
@@ -14,6 +14,7 @@ loki --info | grep 'config_file' | awk '{print $2}'
<!--toc:start--> <!--toc:start-->
- [Supported Clients](#supported-clients) - [Supported Clients](#supported-clients)
- [Client Configuration](#client-configuration) - [Client Configuration](#client-configuration)
- [Authentication](#authentication)
- [Extra Settings](#extra-settings) - [Extra Settings](#extra-settings)
<!--toc:end--> <!--toc:end-->
@@ -51,12 +52,13 @@ clients:
The client metadata uniquely identifies the client in Loki so you can reference it across your configurations. The The client metadata uniquely identifies the client in Loki so you can reference it across your configurations. The
available settings are listed below: available settings are listed below:
| Setting | Description | | Setting | Description |
|----------|-----------------------------------------------------------------------------------------------| |----------|------------------------------------------------------------------------------------------------------------|
| `name` | The name of the client (e.g. `openai`, `gemini`, etc.) | | `name` | The name of the client (e.g. `openai`, `gemini`, etc.) |
| `models` | See the [model settings](#model-settings) documentation below | | `auth` | Authentication method: `oauth` for OAuth, or omit to use `api_key` (see [Authentication](#authentication)) |
| `patch` | See the [client patch configuration](./PATCHES.md#client-configuration-patches) documentation | | `models` | See the [model settings](#model-settings) documentation below |
| `extra` | See the [extra settings](#extra-settings) documentation below | | `patch` | See the [client patch configuration](./PATCHES.md#client-configuration-patches) documentation |
| `extra` | See the [extra settings](#extra-settings) documentation below |
Be sure to also check provider-specific configurations for any extra fields that are added for authentication purposes. Be sure to also check provider-specific configurations for any extra fields that are added for authentication purposes.
@@ -83,6 +85,97 @@ The `models` array lists the available models from the model client. Each one ha
| `default_chunk_size` | | `embedding` | The default chunk size to use with the given model | | `default_chunk_size` | | `embedding` | The default chunk size to use with the given model |
| `max_batch_size` | | `embedding` | The maximum batch size that the given embedding model supports | | `max_batch_size` | | `embedding` | The maximum batch size that the given embedding model supports |
## Authentication
Loki clients support two authentication methods: **API keys** and **OAuth**. Each client entry in your configuration
must use one or the other.
### API Key Authentication
Most clients authenticate using an API key. Simply set the `api_key` field directly or inject it from the
[Loki vault](../VAULT.md):
```yaml
clients:
- type: claude
api_key: '{{ANTHROPIC_API_KEY}}'
```
API keys can also be provided via environment variables named `{CLIENT_NAME}_API_KEY` (e.g. `OPENAI_API_KEY`,
`GEMINI_API_KEY`). See the [environment variables documentation](../ENVIRONMENT-VARIABLES.md#client-related-variables)
for details.
### OAuth Authentication
For [providers that support OAuth](#providers-that-support-oauth), you can authenticate using your existing subscription instead of an API key. This uses
the OAuth 2.0 PKCE flow.
**Step 1: Configure the client**
Add a client entry with `auth: oauth` and no `api_key`:
```yaml
clients:
- type: claude
name: my-claude-oauth
auth: oauth
```
**Step 2: Authenticate**
Run the `--authenticate` flag with the client name:
```sh
loki --authenticate my-claude-oauth
```
Or if you have only one OAuth-configured client, you can omit the name:
```sh
loki --authenticate
```
Alternatively, you can use the REPL command `.authenticate`.
This opens your browser for the OAuth authorization flow. Depending on the provider, Loki will either start a
temporary localhost server to capture the callback automatically (e.g. Gemini) or ask you to paste the authorization
code back into the terminal (e.g. Claude). Loki stores the tokens in `~/.cache/loki/oauth` and automatically refreshes
them when they expire.
#### Gemini OAuth Note
Loki uses the following scopes for OAuth with Gemini:
* https://www.googleapis.com/auth/generative-language.peruserquota
* https://www.googleapis.com/auth/userinfo.email
* https://www.googleapis.com/auth/generative-language.retriever (Sensitive)
Since the `generative-language.retriever` scope is a sensitive scope, Google needs to verify Loki, which requires full
branding (logo, official website, privacy policy, terms of service, etc.). The Loki app is open-source and is designed
to be used as a simple CLI. As such, there's no terms of service or privacy policy associated with it, and thus Google
cannot verify Loki.
So, when you kick off OAuth with Gemini, you may see a page similar to the following:
![](../images/clients/gemini-oauth-page.png)
Simply click the `Advanced` link and click `Go to Loki (unsafe)` to continue the OAuth flow.
![](../images/clients/gemini-oauth-unverified.png)
![](../images/clients/gemini-oauth-unverified-allow.png)
**Step 3: Use normally**
Once authenticated, the client works like any other. Loki uses the stored OAuth tokens automatically:
```sh
loki -m my-claude-oauth:claude-sonnet-4-20250514 "Hello!"
```
> **Note:** You can have multiple clients for the same provider. For example: you can have one with an API key and
> another with OAuth. Use the `name` field to distinguish them.
### Providers That Support OAuth
* Claude
* Gemini
## Extra Settings ## Extra Settings
Loki also lets you customize some extra settings for interacting with APIs: Loki also lets you customize some extra settings for interacting with APIs:
+1
View File
@@ -55,6 +55,7 @@ Loki ships with a `functions/mcp.json` file that includes some useful MCP server
* [github](https://github.com/github/github-mcp-server) - Interact with GitHub repositories, issues, pull requests, and more. * [github](https://github.com/github/github-mcp-server) - Interact with GitHub repositories, issues, pull requests, and more.
* [docker](https://github.com/ckreiling/mcp-server-docker) - Manage your local Docker containers with natural language * [docker](https://github.com/ckreiling/mcp-server-docker) - Manage your local Docker containers with natural language
* [slack](https://github.com/korotovsky/slack-mcp-server) - Interact with Slack * [slack](https://github.com/korotovsky/slack-mcp-server) - Interact with Slack
* [ddg-search](https://github.com/nickclyde/duckduckgo-mcp-server) - Perform web searches with the DuckDuckGo search engine
## Loki Configuration ## Loki Configuration
MCP servers, like tools, can be used in a handful of contexts: MCP servers, like tools, can be used in a handful of contexts:
+259 -136
View File
@@ -3,6 +3,13 @@
# - https://platform.openai.com/docs/api-reference/chat # - https://platform.openai.com/docs/api-reference/chat
- provider: openai - provider: openai
models: models:
- name: gpt-5.2
max_input_tokens: 400000
max_output_tokens: 128000
input_price: 1.75
output_price: 14
supports_vision: true
supports_function_calling: true
- name: gpt-5.1 - name: gpt-5.1
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
@@ -81,6 +88,7 @@
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: o4-mini - name: o4-mini
max_output_tokens: 100000
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 1.1 input_price: 1.1
output_price: 4.4 output_price: 4.4
@@ -93,6 +101,7 @@
temperature: null temperature: null
top_p: null top_p: null
- name: o4-mini-high - name: o4-mini-high
max_output_tokens: 100000
real_name: o4-mini real_name: o4-mini
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 1.1 input_price: 1.1
@@ -107,6 +116,7 @@
temperature: null temperature: null
top_p: null top_p: null
- name: o3 - name: o3
max_output_tokens: 100000
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 2 input_price: 2
output_price: 8 output_price: 8
@@ -133,6 +143,7 @@
temperature: null temperature: null
top_p: null top_p: null
- name: o3-mini - name: o3-mini
max_output_tokens: 100000
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 1.1 input_price: 1.1
output_price: 4.4 output_price: 4.4
@@ -145,6 +156,7 @@
temperature: null temperature: null
top_p: null top_p: null
- name: o3-mini-high - name: o3-mini-high
max_output_tokens: 100000
real_name: o3-mini real_name: o3-mini
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 1.1 input_price: 1.1
@@ -190,25 +202,32 @@
# - https://ai.google.dev/api/rest/v1beta/models/streamGenerateContent # - https://ai.google.dev/api/rest/v1beta/models/streamGenerateContent
- provider: gemini - provider: gemini
models: models:
- name: gemini-3.1-pro-preview
max_input_tokens: 1048576
max_output_tokens: 65535
input_price: 0.3
output_price: 2.5
supports_vision: true
supports_function_calling: true
- name: gemini-2.5-flash - name: gemini-2.5-flash
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65535
input_price: 0 input_price: 0.3
output_price: 0 output_price: 2.5
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: gemini-2.5-pro - name: gemini-2.5-pro
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65536
input_price: 0 input_price: 1.25
output_price: 0 output_price: 10
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: gemini-2.5-flash-lite - name: gemini-2.5-flash-lite
max_input_tokens: 1000000 max_input_tokens: 1048576
max_output_tokens: 64000 max_output_tokens: 65535
input_price: 0 input_price: 0.1
output_price: 0 output_price: 0.4
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: gemini-2.0-flash - name: gemini-2.0-flash
@@ -226,10 +245,11 @@
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: gemma-3-27b-it - name: gemma-3-27b-it
max_input_tokens: 131072 supports_vision: true
max_output_tokens: 8192 max_input_tokens: 128000
input_price: 0 max_output_tokens: 65536
output_price: 0 input_price: 0.04
output_price: 0.15
- name: text-embedding-004 - name: text-embedding-004
type: embedding type: embedding
input_price: 0 input_price: 0
@@ -242,6 +262,54 @@
# - https://docs.anthropic.com/en/api/messages # - https://docs.anthropic.com/en/api/messages
- provider: claude - provider: claude
models: models:
- name: claude-opus-4-6
max_input_tokens: 200000
max_output_tokens: 8192
require_max_tokens: true
input_price: 5
output_price: 25
supports_vision: true
supports_function_calling: true
- name: claude-opus-4-6:thinking
real_name: claude-opus-4-6
max_input_tokens: 200000
max_output_tokens: 24000
require_max_tokens: true
input_price: 5
output_price: 25
supports_vision: true
supports_function_calling: true
patch:
body:
temperature: null
top_p: null
thinking:
type: enabled
budget_tokens: 16000
- name: claude-sonnet-4-6
max_input_tokens: 200000
max_output_tokens: 8192
require_max_tokens: true
input_price: 3
output_price: 15
supports_vision: true
supports_function_calling: true
- name: claude-sonnet-4-6:thinking
real_name: claude-sonnet-4-6
max_input_tokens: 200000
max_output_tokens: 24000
require_max_tokens: true
input_price: 3
output_price: 15
supports_vision: true
supports_function_calling: true
patch:
body:
temperature: null
top_p: null
thinking:
type: enabled
budget_tokens: 16000
- name: claude-sonnet-4-5-20250929 - name: claude-sonnet-4-5-20250929
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 8192
@@ -509,8 +577,8 @@
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
- name: command-r7b-12-2024 - name: command-r7b-12-2024
max_input_tokens: 131072 max_input_tokens: 128000
max_output_tokens: 4096 max_output_tokens: 4000
input_price: 0.0375 input_price: 0.0375
output_price: 0.15 output_price: 0.15
- name: embed-v4.0 - name: embed-v4.0
@@ -547,6 +615,7 @@
- provider: xai - provider: xai
models: models:
- name: grok-4 - name: grok-4
supports_vision: true
max_input_tokens: 256000 max_input_tokens: 256000
input_price: 3 input_price: 3
output_price: 15 output_price: 15
@@ -583,14 +652,18 @@
- provider: perplexity - provider: perplexity
models: models:
- name: sonar-pro - name: sonar-pro
max_output_tokens: 8000
supports_vision: true
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 3 input_price: 3
output_price: 15 output_price: 15
- name: sonar - name: sonar
max_input_tokens: 128000 supports_vision: true
max_input_tokens: 127072
input_price: 1 input_price: 1
output_price: 1 output_price: 1
- name: sonar-reasoning-pro - name: sonar-reasoning-pro
supports_vision: true
max_input_tokens: 128000 max_input_tokens: 128000
input_price: 2 input_price: 2
output_price: 8 output_price: 8
@@ -659,17 +732,16 @@
# - https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/gemini # - https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/gemini
- provider: vertexai - provider: vertexai
models: models:
- name: gemini-3-pro-preview - name: gemini-3.1-pro-preview
hipaa_safe: true
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65536
input_price: 0 input_price: 2
output_price: 0 output_price: 12
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: gemini-2.5-flash - name: gemini-2.5-flash
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65535
input_price: 0.3 input_price: 0.3
output_price: 2.5 output_price: 2.5
supports_vision: true supports_vision: true
@@ -683,16 +755,16 @@
supports_function_calling: true supports_function_calling: true
- name: gemini-2.5-flash-lite - name: gemini-2.5-flash-lite
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 65536 max_output_tokens: 65535
input_price: 0.3 input_price: 0.1
output_price: 0.4 output_price: 0.4
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: gemini-2.0-flash-001 - name: gemini-2.0-flash-001
max_input_tokens: 1048576 max_input_tokens: 1048576
max_output_tokens: 8192 max_output_tokens: 8192
input_price: 0.15 input_price: 0.1
output_price: 0.6 output_price: 0.4
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: gemini-2.0-flash-lite-001 - name: gemini-2.0-flash-lite-001
@@ -1187,17 +1259,22 @@
max_input_tokens: 1024 max_input_tokens: 1024
input_price: 0.07 input_price: 0.07
# Links: # Links:
# - https://help.aliyun.com/zh/model-studio/getting-started/models # - https://help.aliyun.com/zh/model-studio/getting-started/models
# - https://help.aliyun.com/zh/model-studio/developer-reference/use-qwen-by-calling-api # - https://help.aliyun.com/zh/model-studio/developer-reference/use-qwen-by-calling-api
- provider: qianwen - provider: qianwen
models: models:
- name: qwen3-max - name: qwen3-max
input_price: 1.2
output_price: 6
max_output_tokens: 32768
max_input_tokens: 262144 max_input_tokens: 262144
supports_function_calling: true supports_function_calling: true
- name: qwen-plus - name: qwen-plus
max_input_tokens: 131072 input_price: 0.4
output_price: 1.2
max_output_tokens: 32768
max_input_tokens: 1000000
supports_function_calling: true supports_function_calling: true
- name: qwen-flash - name: qwen-flash
max_input_tokens: 1000000 max_input_tokens: 1000000
@@ -1213,14 +1290,14 @@
- name: qwen-coder-flash - name: qwen-coder-flash
max_input_tokens: 1000000 max_input_tokens: 1000000
- name: qwen3-next-80b-a3b-instruct - name: qwen3-next-80b-a3b-instruct
max_input_tokens: 131072 max_input_tokens: 262144
input_price: 0.14 input_price: 0.09
output_price: 0.56 output_price: 1.1
supports_function_calling: true supports_function_calling: true
- name: qwen3-next-80b-a3b-thinking - name: qwen3-next-80b-a3b-thinking
max_input_tokens: 131072 max_input_tokens: 128000
input_price: 0.14 input_price: 0.15
output_price: 1.4 output_price: 1.2
- name: qwen3-235b-a22b-instruct-2507 - name: qwen3-235b-a22b-instruct-2507
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.28 input_price: 0.28
@@ -1228,35 +1305,39 @@
supports_function_calling: true supports_function_calling: true
- name: qwen3-235b-a22b-thinking-2507 - name: qwen3-235b-a22b-thinking-2507
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.28 input_price: 0
output_price: 2.8 output_price: 0
- name: qwen3-30b-a3b-instruct-2507 - name: qwen3-30b-a3b-instruct-2507
max_input_tokens: 131072 max_output_tokens: 262144
input_price: 0.105 max_input_tokens: 262144
output_price: 0.42 input_price: 0.09
output_price: 0.3
supports_function_calling: true supports_function_calling: true
- name: qwen3-30b-a3b-thinking-2507 - name: qwen3-30b-a3b-thinking-2507
max_input_tokens: 131072 max_input_tokens: 32768
input_price: 0.105 input_price: 0.051
output_price: 1.05 output_price: 0.34
- name: qwen3-vl-32b-instruct - name: qwen3-vl-32b-instruct
max_output_tokens: 32768
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.28 input_price: 0.104
output_price: 1.12 output_price: 0.416
supports_vision: true supports_vision: true
- name: qwen3-vl-8b-instruct - name: qwen3-vl-8b-instruct
max_output_tokens: 32768
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.07 input_price: 0.08
output_price: 0.28 output_price: 0.5
supports_vision: true supports_vision: true
- name: qwen3-coder-480b-a35b-instruct - name: qwen3-coder-480b-a35b-instruct
max_input_tokens: 262144 max_input_tokens: 262144
input_price: 1.26 input_price: 1.26
output_price: 5.04 output_price: 5.04
- name: qwen3-coder-30b-a3b-instruct - name: qwen3-coder-30b-a3b-instruct
max_input_tokens: 262144 max_output_tokens: 32768
input_price: 0.315 max_input_tokens: 160000
output_price: 1.26 input_price: 0.07
output_price: 0.27
- name: deepseek-v3.2-exp - name: deepseek-v3.2-exp
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.28 input_price: 0.28
@@ -1332,9 +1413,9 @@
output_price: 8.12 output_price: 8.12
supports_vision: true supports_vision: true
- name: kimi-k2-thinking - name: kimi-k2-thinking
max_input_tokens: 262144 max_input_tokens: 131072
input_price: 0.56 input_price: 0.47
output_price: 2.24 output_price: 2
supports_vision: true supports_vision: true
# Links: # Links:
@@ -1343,10 +1424,10 @@
- provider: deepseek - provider: deepseek
models: models:
- name: deepseek-chat - name: deepseek-chat
max_input_tokens: 64000 max_input_tokens: 163840
max_output_tokens: 8192 max_output_tokens: 163840
input_price: 0.56 input_price: 0.32
output_price: 1.68 output_price: 0.89
supports_function_calling: true supports_function_calling: true
- name: deepseek-reasoner - name: deepseek-reasoner
max_input_tokens: 64000 max_input_tokens: 64000
@@ -1424,9 +1505,10 @@
- provider: minimax - provider: minimax
models: models:
- name: minimax-m2 - name: minimax-m2
max_input_tokens: 204800 max_output_tokens: 65536
input_price: 0.294 max_input_tokens: 196608
output_price: 1.176 input_price: 0.255
output_price: 1
supports_function_calling: true supports_function_calling: true
# Links: # Links:
@@ -1442,8 +1524,8 @@
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: openai/gpt-5.1-chat - name: openai/gpt-5.1-chat
max_input_tokens: 400000 max_input_tokens: 128000
max_output_tokens: 128000 max_output_tokens: 16384
input_price: 1.25 input_price: 1.25
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
@@ -1456,8 +1538,8 @@
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: openai/gpt-5-chat - name: openai/gpt-5-chat
max_input_tokens: 400000 max_input_tokens: 128000
max_output_tokens: 128000 max_output_tokens: 16384
input_price: 1.25 input_price: 1.25
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
@@ -1498,18 +1580,21 @@
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: openai/gpt-4o - name: openai/gpt-4o
max_output_tokens: 16384
max_input_tokens: 128000 max_input_tokens: 128000
input_price: 2.5 input_price: 2.5
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: openai/gpt-4o-mini - name: openai/gpt-4o-mini
max_output_tokens: 16384
max_input_tokens: 128000 max_input_tokens: 128000
input_price: 0.15 input_price: 0.15
output_price: 0.6 output_price: 0.6
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: openai/o4-mini - name: openai/o4-mini
max_output_tokens: 100000
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 1.1 input_price: 1.1
output_price: 4.4 output_price: 4.4
@@ -1522,6 +1607,7 @@
temperature: null temperature: null
top_p: null top_p: null
- name: openai/o4-mini-high - name: openai/o4-mini-high
max_output_tokens: 100000
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 1.1 input_price: 1.1
output_price: 4.4 output_price: 4.4
@@ -1535,6 +1621,7 @@
temperature: null temperature: null
top_p: null top_p: null
- name: openai/o3 - name: openai/o3
max_output_tokens: 100000
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 2 input_price: 2
output_price: 8 output_price: 8
@@ -1560,6 +1647,7 @@
temperature: null temperature: null
top_p: null top_p: null
- name: openai/o3-mini - name: openai/o3-mini
max_output_tokens: 100000
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 1.1 input_price: 1.1
output_price: 4.4 output_price: 4.4
@@ -1571,6 +1659,7 @@
temperature: null temperature: null
top_p: null top_p: null
- name: openai/o3-mini-high - name: openai/o3-mini-high
max_output_tokens: 100000
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 1.1 input_price: 1.1
output_price: 4.4 output_price: 4.4
@@ -1583,50 +1672,57 @@
top_p: null top_p: null
- name: openai/gpt-oss-120b - name: openai/gpt-oss-120b
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.09 input_price: 0.039
output_price: 0.45 output_price: 0.19
supports_function_calling: true supports_function_calling: true
- name: openai/gpt-oss-20b - name: openai/gpt-oss-20b
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.04 input_price: 0.03
output_price: 0.16 output_price: 0.14
supports_function_calling: true supports_function_calling: true
- name: google/gemini-2.5-flash - name: google/gemini-2.5-flash
max_output_tokens: 65535
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 0.3 input_price: 0.3
output_price: 2.5 output_price: 2.5
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: google/gemini-2.5-pro - name: google/gemini-2.5-pro
max_output_tokens: 65536
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 1.25 input_price: 1.25
output_price: 10 output_price: 10
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: google/gemini-2.5-flash-lite - name: google/gemini-2.5-flash-lite
max_output_tokens: 65535
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 0.3 input_price: 0.1
output_price: 0.4 output_price: 0.4
supports_vision: true supports_vision: true
- name: google/gemini-2.0-flash-001 - name: google/gemini-2.0-flash-001
max_input_tokens: 1000000 max_output_tokens: 8192
input_price: 0.15 max_input_tokens: 1048576
output_price: 0.6 input_price: 0.1
output_price: 0.4
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: google/gemini-2.0-flash-lite-001 - name: google/gemini-2.0-flash-lite-001
max_output_tokens: 8192
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 0.075 input_price: 0.075
output_price: 0.3 output_price: 0.3
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: google/gemma-3-27b-it - name: google/gemma-3-27b-it
max_input_tokens: 131072 max_output_tokens: 65536
input_price: 0.1 supports_vision: true
output_price: 0.2 max_input_tokens: 128000
input_price: 0.04
output_price: 0.15
- name: anthropic/claude-sonnet-4.5 - name: anthropic/claude-sonnet-4.5
max_input_tokens: 200000 max_input_tokens: 1000000
max_output_tokens: 8192 max_output_tokens: 64000
require_max_tokens: true require_max_tokens: true
input_price: 3 input_price: 3
output_price: 15 output_price: 15
@@ -1634,7 +1730,7 @@
supports_function_calling: true supports_function_calling: true
- name: anthropic/claude-haiku-4.5 - name: anthropic/claude-haiku-4.5
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 64000
require_max_tokens: true require_max_tokens: true
input_price: 1 input_price: 1
output_price: 5 output_price: 5
@@ -1642,7 +1738,7 @@
supports_function_calling: true supports_function_calling: true
- name: anthropic/claude-opus-4.1 - name: anthropic/claude-opus-4.1
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 32000
require_max_tokens: true require_max_tokens: true
input_price: 15 input_price: 15
output_price: 75 output_price: 75
@@ -1650,15 +1746,15 @@
supports_function_calling: true supports_function_calling: true
- name: anthropic/claude-opus-4 - name: anthropic/claude-opus-4
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 32000
require_max_tokens: true require_max_tokens: true
input_price: 15 input_price: 15
output_price: 75 output_price: 75
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: anthropic/claude-sonnet-4 - name: anthropic/claude-sonnet-4
max_input_tokens: 200000 max_input_tokens: 1000000
max_output_tokens: 8192 max_output_tokens: 64000
require_max_tokens: true require_max_tokens: true
input_price: 3 input_price: 3
output_price: 15 output_price: 15
@@ -1666,7 +1762,7 @@
supports_function_calling: true supports_function_calling: true
- name: anthropic/claude-3.7-sonnet - name: anthropic/claude-3.7-sonnet
max_input_tokens: 200000 max_input_tokens: 200000
max_output_tokens: 8192 max_output_tokens: 64000
require_max_tokens: true require_max_tokens: true
input_price: 3 input_price: 3
output_price: 15 output_price: 15
@@ -1681,21 +1777,24 @@
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: meta-llama/llama-4-maverick - name: meta-llama/llama-4-maverick
max_output_tokens: 16384
max_input_tokens: 1048576 max_input_tokens: 1048576
input_price: 0.18 input_price: 0.15
output_price: 0.6 output_price: 0.6
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: meta-llama/llama-4-scout - name: meta-llama/llama-4-scout
max_output_tokens: 16384
max_input_tokens: 327680 max_input_tokens: 327680
input_price: 0.08 input_price: 0.08
output_price: 0.3 output_price: 0.3
supports_vision: true supports_vision: true
supports_function_calling: true supports_function_calling: true
- name: meta-llama/llama-3.3-70b-instruct - name: meta-llama/llama-3.3-70b-instruct
max_output_tokens: 16384
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.12 input_price: 0.1
output_price: 0.3 output_price: 0.32
- name: mistralai/mistral-medium-3.1 - name: mistralai/mistral-medium-3.1
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.4 input_price: 0.4
@@ -1703,9 +1802,10 @@
supports_function_calling: true supports_function_calling: true
supports_vision: true supports_vision: true
- name: mistralai/mistral-small-3.2-24b-instruct - name: mistralai/mistral-small-3.2-24b-instruct
max_output_tokens: 131072
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.1 input_price: 0.06
output_price: 0.3 output_price: 0.18
supports_vision: true supports_vision: true
- name: mistralai/magistral-medium-2506 - name: mistralai/magistral-medium-2506
max_input_tokens: 40960 max_input_tokens: 40960
@@ -1726,8 +1826,8 @@
supports_function_calling: true supports_function_calling: true
- name: mistralai/devstral-small - name: mistralai/devstral-small
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.07 input_price: 0.1
output_price: 0.28 output_price: 0.3
supports_function_calling: true supports_function_calling: true
- name: mistralai/codestral-2508 - name: mistralai/codestral-2508
max_input_tokens: 256000 max_input_tokens: 256000
@@ -1735,6 +1835,7 @@
output_price: 0.9 output_price: 0.9
supports_function_calling: true supports_function_calling: true
- name: ai21/jamba-large-1.7 - name: ai21/jamba-large-1.7
max_output_tokens: 4096
max_input_tokens: 256000 max_input_tokens: 256000
input_price: 2 input_price: 2
output_price: 8 output_price: 8
@@ -1745,110 +1846,121 @@
output_price: 0.4 output_price: 0.4
supports_function_calling: true supports_function_calling: true
- name: cohere/command-a - name: cohere/command-a
max_output_tokens: 8192
max_input_tokens: 256000 max_input_tokens: 256000
input_price: 2.5 input_price: 2.5
output_price: 10 output_price: 10
supports_function_calling: true supports_function_calling: true
- name: cohere/command-r7b-12-2024 - name: cohere/command-r7b-12-2024
max_input_tokens: 128000 max_input_tokens: 128000
max_output_tokens: 4096 max_output_tokens: 4000
input_price: 0.0375 input_price: 0.0375
output_price: 0.15 output_price: 0.15
- name: deepseek/deepseek-v3.2-exp - name: deepseek/deepseek-v3.2-exp
max_output_tokens: 65536
max_input_tokens: 163840 max_input_tokens: 163840
input_price: 0.27 input_price: 0.27
output_price: 0.40 output_price: 0.41
- name: deepseek/deepseek-v3.1-terminus - name: deepseek/deepseek-v3.1-terminus
max_input_tokens: 163840 max_input_tokens: 163840
input_price: 0.23 input_price: 0.21
output_price: 0.90 output_price: 0.79
- name: deepseek/deepseek-chat-v3.1 - name: deepseek/deepseek-chat-v3.1
max_input_tokens: 163840 max_output_tokens: 7168
input_price: 0.2 max_input_tokens: 32768
output_price: 0.8 input_price: 0.15
output_price: 0.75
- name: deepseek/deepseek-r1-0528 - name: deepseek/deepseek-r1-0528
max_input_tokens: 128000 max_output_tokens: 65536
input_price: 0.50 max_input_tokens: 163840
output_price: 2.15 input_price: 0.4
output_price: 1.75
patch: patch:
body: body:
include_reasoning: true include_reasoning: true
- name: qwen/qwen3-max - name: qwen/qwen3-max
max_output_tokens: 32768
max_input_tokens: 262144 max_input_tokens: 262144
input_price: 1.2 input_price: 1.2
output_price: 6 output_price: 6
supports_function_calling: true supports_function_calling: true
- name: qwen/qwen-plus - name: qwen/qwen-plus
max_input_tokens: 131072 max_input_tokens: 1000000
max_output_tokens: 8192 max_output_tokens: 32768
input_price: 0.4 input_price: 0.4
output_price: 1.2 output_price: 1.2
supports_function_calling: true supports_function_calling: true
- name: qwen/qwen3-next-80b-a3b-instruct - name: qwen/qwen3-next-80b-a3b-instruct
max_input_tokens: 262144 max_input_tokens: 262144
input_price: 0.1 input_price: 0.09
output_price: 0.8 output_price: 1.1
supports_function_calling: true supports_function_calling: true
- name: qwen/qwen3-next-80b-a3b-thinking - name: qwen/qwen3-next-80b-a3b-thinking
max_input_tokens: 262144 max_input_tokens: 128000
input_price: 0.1 input_price: 0.15
output_price: 0.8 output_price: 1.2
- name: qwen/qwen5-235b-a22b-2507 # Qwen3 235B A22B Instruct 2507 - name: qwen/qwen5-235b-a22b-2507 # Qwen3 235B A22B Instruct 2507
max_input_tokens: 262144 max_input_tokens: 262144
input_price: 0.12 input_price: 0.12
output_price: 0.59 output_price: 0.59
supports_function_calling: true supports_function_calling: true
- name: qwen/qwen3-235b-a22b-thinking-2507 - name: qwen/qwen3-235b-a22b-thinking-2507
max_input_tokens: 262144
input_price: 0.118
output_price: 0.118
- name: qwen/qwen3-30b-a3b-instruct-2507
max_input_tokens: 131072 max_input_tokens: 131072
input_price: 0.2 input_price: 0
output_price: 0.8 output_price: 0
- name: qwen/qwen3-30b-a3b-instruct-2507
max_output_tokens: 262144
max_input_tokens: 262144
input_price: 0.09
output_price: 0.3
- name: qwen/qwen3-30b-a3b-thinking-2507 - name: qwen/qwen3-30b-a3b-thinking-2507
max_input_tokens: 262144 max_input_tokens: 32768
input_price: 0.071 input_price: 0.051
output_price: 0.285 output_price: 0.34
- name: qwen/qwen3-vl-32b-instruct - name: qwen/qwen3-vl-32b-instruct
max_input_tokens: 262144 max_output_tokens: 32768
input_price: 0.35 max_input_tokens: 131072
output_price: 1.1 input_price: 0.104
output_price: 0.416
supports_vision: true supports_vision: true
- name: qwen/qwen3-vl-8b-instruct - name: qwen/qwen3-vl-8b-instruct
max_input_tokens: 262144 max_output_tokens: 32768
max_input_tokens: 131072
input_price: 0.08 input_price: 0.08
output_price: 0.50 output_price: 0.5
supports_vision: true supports_vision: true
- name: qwen/qwen3-coder-plus - name: qwen/qwen3-coder-plus
max_input_tokens: 128000 max_output_tokens: 65536
max_input_tokens: 1000000
input_price: 1 input_price: 1
output_price: 5 output_price: 5
supports_function_calling: true supports_function_calling: true
- name: qwen/qwen3-coder-flash - name: qwen/qwen3-coder-flash
max_input_tokens: 128000 max_output_tokens: 65536
max_input_tokens: 1000000
input_price: 0.3 input_price: 0.3
output_price: 1.5 output_price: 1.5
supports_function_calling: true supports_function_calling: true
- name: qwen/qwen3-coder # Qwen3 Coder 480B A35B - name: qwen/qwen3-coder # Qwen3 Coder 480B A35B
max_input_tokens: 262144 max_input_tokens: 262144
input_price: 0.22 input_price: 0.22
output_price: 0.95 output_price: 0.95
supports_function_calling: true supports_function_calling: true
- name: qwen/qwen3-coder-30b-a3b-instruct - name: qwen/qwen3-coder-30b-a3b-instruct
max_input_tokens: 262144 max_output_tokens: 32768
input_price: 0.052 max_input_tokens: 160000
output_price: 0.207 input_price: 0.07
output_price: 0.27
supports_function_calling: true supports_function_calling: true
- name: moonshotai/kimi-k2-0905 - name: moonshotai/kimi-k2-0905
max_input_tokens: 262144 max_input_tokens: 131072
input_price: 0.296 input_price: 0.4
output_price: 1.185 output_price: 2
supports_function_calling: true supports_function_calling: true
- name: moonshotai/kimi-k2-thinking - name: moonshotai/kimi-k2-thinking
max_input_tokens: 262144 max_input_tokens: 131072
input_price: 0.45 input_price: 0.47
output_price: 2.35 output_price: 2
supports_function_calling: true supports_function_calling: true
- name: moonshotai/kimi-dev-72b - name: moonshotai/kimi-dev-72b
max_input_tokens: 131072 max_input_tokens: 131072
@@ -1856,21 +1968,26 @@
output_price: 1.15 output_price: 1.15
supports_function_calling: true supports_function_calling: true
- name: x-ai/grok-4 - name: x-ai/grok-4
supports_vision: true
max_input_tokens: 256000 max_input_tokens: 256000
input_price: 3 input_price: 3
output_price: 15 output_price: 15
supports_function_calling: true supports_function_calling: true
- name: x-ai/grok-4-fast - name: x-ai/grok-4-fast
max_output_tokens: 30000
supports_vision: true
max_input_tokens: 2000000 max_input_tokens: 2000000
input_price: 0.2 input_price: 0.2
output_price: 0.5 output_price: 0.5
supports_function_calling: true supports_function_calling: true
- name: x-ai/grok-code-fast-1 - name: x-ai/grok-code-fast-1
max_output_tokens: 10000
max_input_tokens: 256000 max_input_tokens: 256000
input_price: 0.2 input_price: 0.2
output_price: 1.5 output_price: 1.5
supports_function_calling: true supports_function_calling: true
- name: amazon/nova-premier-v1 - name: amazon/nova-premier-v1
max_output_tokens: 32000
max_input_tokens: 1000000 max_input_tokens: 1000000
input_price: 2.5 input_price: 2.5
output_price: 12.5 output_price: 12.5
@@ -1893,14 +2010,18 @@
input_price: 0.035 input_price: 0.035
output_price: 0.14 output_price: 0.14
- name: perplexity/sonar-pro - name: perplexity/sonar-pro
max_output_tokens: 8000
supports_vision: true
max_input_tokens: 200000 max_input_tokens: 200000
input_price: 3 input_price: 3
output_price: 15 output_price: 15
- name: perplexity/sonar - name: perplexity/sonar
supports_vision: true
max_input_tokens: 127072 max_input_tokens: 127072
input_price: 1 input_price: 1
output_price: 1 output_price: 1
- name: perplexity/sonar-reasoning-pro - name: perplexity/sonar-reasoning-pro
supports_vision: true
max_input_tokens: 128000 max_input_tokens: 128000
input_price: 2 input_price: 2
output_price: 8 output_price: 8
@@ -1915,20 +2036,22 @@
body: body:
include_reasoning: true include_reasoning: true
- name: perplexity/sonar-deep-research - name: perplexity/sonar-deep-research
max_input_tokens: 200000 max_input_tokens: 128000
input_price: 2 input_price: 2
output_price: 8 output_price: 8
patch: patch:
body: body:
include_reasoning: true include_reasoning: true
- name: minimax/minimax-m2 - name: minimax/minimax-m2
max_output_tokens: 65536
max_input_tokens: 196608 max_input_tokens: 196608
input_price: 0.15 input_price: 0.255
output_price: 0.45 output_price: 1
- name: z-ai/glm-4.6 - name: z-ai/glm-4.6
max_output_tokens: 131072
max_input_tokens: 202752 max_input_tokens: 202752
input_price: 0.5 input_price: 0.35
output_price: 1.75 output_price: 1.71
supports_function_calling: true supports_function_calling: true
# Links: # Links:
+2
View File
@@ -0,0 +1,2 @@
requests
ruamel.yaml
+255
View File
@@ -0,0 +1,255 @@
import requests
import sys
import re
import json
# Provider mapping from models.yaml to OpenRouter prefixes
PROVIDER_MAPPING = {
"openai": "openai",
"claude": "anthropic",
"gemini": "google",
"mistral": "mistralai",
"cohere": "cohere",
"perplexity": "perplexity",
"xai": "x-ai",
"openrouter": "openrouter",
"ai21": "ai21",
"deepseek": "deepseek",
"moonshot": "moonshotai",
"qianwen": "qwen",
"zhipuai": "zhipuai",
"minimax": "minimax",
"vertexai": "google",
"groq": "groq",
"bedrock": "amazon",
"hunyuan": "tencent",
"ernie": "baidu",
"github": "github",
}
def fetch_openrouter_models():
print("Fetching models from OpenRouter...")
try:
response = requests.get("https://openrouter.ai/api/v1/models")
response.raise_for_status()
data = response.json()["data"]
print(f"Fetched {len(data)} models.")
return data
except Exception as e:
print(f"Error fetching models: {e}")
sys.exit(1)
def get_openrouter_model(models_data, provider_prefix, model_name, is_openrouter_provider=False):
if is_openrouter_provider:
# For openrouter provider, the model_name in yaml is usually the full ID
for model in models_data:
if model["id"] == model_name:
return model
return None
expected_id = f"{provider_prefix}/{model_name}"
# 1. Try exact match on ID
for model in models_data:
if model["id"] == expected_id:
return model
# 2. Try match by suffix
for model in models_data:
if model["id"].split("/")[-1] == model_name:
if model["id"].startswith(f"{provider_prefix}/"):
return model
return None
def format_price(price_per_token):
if price_per_token is None:
return None
try:
price_per_1m = float(price_per_token) * 1_000_000
if price_per_1m.is_integer():
return str(int(price_per_1m))
else:
return str(round(price_per_1m, 4))
except:
return None
def get_indentation(line):
return len(line) - len(line.lstrip())
def process_model_block(block_lines, current_provider, or_models):
if not block_lines:
return []
# 1. Identify model name and indentation
name_line = block_lines[0]
name_match = re.match(r"^(\s*)-\s*name:\s*(.+)$", name_line)
if not name_match:
return block_lines
name_indent_str = name_match.group(1)
model_name = name_match.group(2).strip()
# 2. Find OpenRouter model
or_prefix = PROVIDER_MAPPING.get(current_provider)
is_openrouter_provider = (current_provider == "openrouter")
if not or_prefix and not is_openrouter_provider:
return block_lines
or_model = get_openrouter_model(or_models, or_prefix, model_name, is_openrouter_provider)
if not or_model:
return block_lines
print(f" Updating {model_name}...")
# 3. Prepare updates
updates = {}
# Pricing
pricing = or_model.get("pricing", {})
p_in = format_price(pricing.get("prompt"))
p_out = format_price(pricing.get("completion"))
if p_in: updates["input_price"] = p_in
if p_out: updates["output_price"] = p_out
# Context
ctx = or_model.get("context_length")
if ctx: updates["max_input_tokens"] = str(ctx)
max_out = None
if "top_provider" in or_model and or_model["top_provider"]:
max_out = or_model["top_provider"].get("max_completion_tokens")
if max_out: updates["max_output_tokens"] = str(max_out)
# Capabilities
arch = or_model.get("architecture", {})
modality = arch.get("modality", "")
if "image" in modality:
updates["supports_vision"] = "true"
# 4. Detect field indentation
field_indent_str = None
existing_fields = {} # key -> line_index
for i, line in enumerate(block_lines):
if i == 0: continue # Skip name line
# Skip comments
if line.strip().startswith("#"):
continue
# Look for "key: value"
m = re.match(r"^(\s*)([\w_-]+):", line)
if m:
indent = m.group(1)
key = m.group(2)
# Must be deeper than name line
if len(indent) > len(name_indent_str):
if field_indent_str is None:
field_indent_str = indent
existing_fields[key] = i
if field_indent_str is None:
field_indent_str = name_indent_str + " "
# 5. Apply updates
new_block = list(block_lines)
# Update existing fields
for key, value in updates.items():
if key in existing_fields:
idx = existing_fields[key]
# Preserve original key indentation exactly
original_line = new_block[idx]
m = re.match(r"^(\s*)([\w_-]+):", original_line)
if m:
current_indent = m.group(1)
new_block[idx] = f"{current_indent}{key}: {value}\n"
# Insert missing fields
# Insert after the name line
insertion_idx = 1
for key, value in updates.items():
if key not in existing_fields:
new_line = f"{field_indent_str}{key}: {value}\n"
new_block.insert(insertion_idx, new_line)
insertion_idx += 1
return new_block
def main():
or_models = fetch_openrouter_models()
print("Reading models.yaml...")
with open("models.yaml", "r") as f:
lines = f.readlines()
new_lines = []
current_provider = None
i = 0
while i < len(lines):
line = lines[i]
# Check for provider
# - provider: name
p_match = re.match(r"^\s*-?\s*provider:\s*(.+)$", line)
if p_match:
current_provider = p_match.group(1).strip()
new_lines.append(line)
i += 1
continue
# Check for model start
# - name: ...
m_match = re.match(r"^(\s*)-\s*name:\s*.+$", line)
if m_match:
# Start of a model block
start_indent = len(m_match.group(1))
# Collect block lines
block_lines = [line]
j = i + 1
while j < len(lines):
next_line = lines[j]
stripped = next_line.strip()
# If empty or comment, include it
if not stripped or stripped.startswith("#"):
block_lines.append(next_line)
j += 1
continue
# Check indentation
next_indent = get_indentation(next_line)
# If indentation is greater, it's part of the block (property)
if next_indent > start_indent:
block_lines.append(next_line)
j += 1
continue
# If indentation is equal or less, it's the end of the block
break
# Process the block
processed_block = process_model_block(block_lines, current_provider, or_models)
new_lines.extend(processed_block)
# Advance i
i = j
continue
# Otherwise, just a regular line
new_lines.append(line)
i += 1
print("Saving models.yaml...")
with open("models.yaml", "w") as f:
f.writelines(new_lines)
print("Done.")
if __name__ == "__main__":
main()
+3
View File
@@ -127,6 +127,9 @@ pub struct Cli {
/// List all secrets stored in the Loki vault /// List all secrets stored in the Loki vault
#[arg(long, exclusive = true)] #[arg(long, exclusive = true)]
pub list_secrets: bool, pub list_secrets: bool,
/// Authenticate with an LLM provider using OAuth (e.g., --authenticate client_name)
#[arg(long, exclusive = true, value_name = "CLIENT_NAME")]
pub authenticate: Option<Option<String>>,
/// Generate static shell completion scripts /// Generate static shell completion scripts
#[arg(long, value_name = "SHELL", value_enum)] #[arg(long, value_name = "SHELL", value_enum)]
pub completions: Option<ShellCompletion>, pub completions: Option<ShellCompletion>,
+3 -3
View File
@@ -19,15 +19,15 @@ impl AzureOpenAIClient {
config_get_fn!(api_base, get_api_base); config_get_fn!(api_base, get_api_base);
config_get_fn!(api_key, get_api_key); config_get_fn!(api_key, get_api_key);
pub const PROMPTS: [PromptAction<'static>; 2] = [ create_client_config!([
( (
"api_base", "api_base",
"API Base", "API Base",
Some("e.g. https://{RESOURCE}.openai.azure.com"), Some("e.g. https://{RESOURCE}.openai.azure.com"),
false false,
), ),
("api_key", "API Key", None, true), ("api_key", "API Key", None, true),
]; ]);
} }
impl_client_trait!( impl_client_trait!(
+2 -2
View File
@@ -32,11 +32,11 @@ impl BedrockClient {
config_get_fn!(region, get_region); config_get_fn!(region, get_region);
config_get_fn!(session_token, get_session_token); config_get_fn!(session_token, get_session_token);
pub const PROMPTS: [PromptAction<'static>; 3] = [ create_client_config!([
("access_key_id", "AWS Access Key ID", None, true), ("access_key_id", "AWS Access Key ID", None, true),
("secret_access_key", "AWS Secret Access Key", None, true), ("secret_access_key", "AWS Secret Access Key", None, true),
("region", "AWS Region", None, false), ("region", "AWS Region", None, false),
]; ]);
fn chat_completions_builder( fn chat_completions_builder(
&self, &self,
+103 -15
View File
@@ -1,19 +1,24 @@
use super::access_token::get_access_token;
use super::claude_oauth::ClaudeOAuthProvider;
use super::oauth::{self, OAuthProvider};
use super::*; use super::*;
use crate::utils::strip_think_tag; use crate::utils::strip_think_tag;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use reqwest::RequestBuilder; use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
const API_BASE: &str = "https://api.anthropic.com/v1"; const API_BASE: &str = "https://api.anthropic.com/v1";
const CLAUDE_CODE_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude.";
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct ClaudeConfig { pub struct ClaudeConfig {
pub name: Option<String>, pub name: Option<String>,
pub api_key: Option<String>, pub api_key: Option<String>,
pub api_base: Option<String>, pub api_base: Option<String>,
pub auth: Option<String>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
@@ -24,25 +29,44 @@ impl ClaudeClient {
config_get_fn!(api_key, get_api_key); config_get_fn!(api_key, get_api_key);
config_get_fn!(api_base, get_api_base); config_get_fn!(api_base, get_api_base);
pub const PROMPTS: [PromptAction<'static>; 1] = [("api_key", "API Key", None, true)]; create_oauth_supported_client_config!();
} }
impl_client_trait!( #[async_trait::async_trait]
ClaudeClient, impl Client for ClaudeClient {
( client_common_fns!();
prepare_chat_completions,
claude_chat_completions,
claude_chat_completions_streaming
),
(noop_prepare_embeddings, noop_embeddings),
(noop_prepare_rerank, noop_rerank),
);
fn prepare_chat_completions( fn supports_oauth(&self) -> bool {
self.config.auth.as_deref() == Some("oauth")
}
async fn chat_completions_inner(
&self,
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
let request_data = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
claude_chat_completions(builder, self.model()).await
}
async fn chat_completions_streaming_inner(
&self,
client: &ReqwestClient,
handler: &mut SseHandler,
data: ChatCompletionsData,
) -> Result<()> {
let request_data = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
claude_chat_completions_streaming(builder, handler, self.model()).await
}
}
async fn prepare_chat_completions(
self_: &ClaudeClient, self_: &ClaudeClient,
client: &ReqwestClient,
data: ChatCompletionsData, data: ChatCompletionsData,
) -> Result<RequestData> { ) -> Result<RequestData> {
let api_key = self_.get_api_key()?;
let api_base = self_ let api_base = self_
.get_api_base() .get_api_base()
.unwrap_or_else(|_| API_BASE.to_string()); .unwrap_or_else(|_| API_BASE.to_string());
@@ -53,11 +77,75 @@ fn prepare_chat_completions(
let mut request_data = RequestData::new(url, body); let mut request_data = RequestData::new(url, body);
request_data.header("anthropic-version", "2023-06-01"); request_data.header("anthropic-version", "2023-06-01");
request_data.header("x-api-key", api_key);
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if uses_oauth {
let provider = ClaudeOAuthProvider;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
if !ready {
bail!(
"OAuth configured but no tokens found for '{}'. Run: 'loki --authenticate {}' or '.authenticate' in the REPL",
self_.name(),
self_.name()
);
}
let token = get_access_token(self_.name())?;
request_data.bearer_auth(token);
for (key, value) in provider.extra_request_headers() {
request_data.header(key, value);
}
inject_oauth_system_prompt(&mut request_data.body);
} else if let Ok(api_key) = self_.get_api_key() {
request_data.header("x-api-key", api_key);
} else {
bail!(
"No authentication configured for '{}'. Set `api_key` or use `auth: oauth` with `loki --authenticate {}`.",
self_.name(),
self_.name()
);
}
Ok(request_data) Ok(request_data)
} }
/// Anthropic requires OAuth-authenticated requests to include a Claude Code
/// system prompt prefix in order to consider a request body as "valid".
///
/// This behavior was discovered 2026-03-17.
///
/// So this function injects the Claude Code system prompt into the request
/// body to make it a valid request.
fn inject_oauth_system_prompt(body: &mut Value) {
let prefix_block = json!({
"type": "text",
"text": CLAUDE_CODE_PREFIX,
});
match body.get("system") {
Some(Value::String(existing)) => {
let existing_block = json!({
"type": "text",
"text": existing,
});
body["system"] = json!([prefix_block, existing_block]);
}
Some(Value::Array(_)) => {
if let Some(arr) = body["system"].as_array_mut() {
let already_injected = arr
.iter()
.any(|block| block["text"].as_str() == Some(CLAUDE_CODE_PREFIX));
if !already_injected {
arr.insert(0, prefix_block);
}
}
}
_ => {
body["system"] = json!([prefix_block]);
}
}
}
pub async fn claude_chat_completions( pub async fn claude_chat_completions(
builder: RequestBuilder, builder: RequestBuilder,
_model: &Model, _model: &Model,
+43
View File
@@ -0,0 +1,43 @@
use super::oauth::OAuthProvider;
pub const BETA_HEADER: &str = "oauth-2025-04-20";
pub struct ClaudeOAuthProvider;
impl OAuthProvider for ClaudeOAuthProvider {
fn provider_name(&self) -> &str {
"claude"
}
fn client_id(&self) -> &str {
"9d1c250a-e61b-44d9-88ed-5944d1962f5e"
}
fn authorize_url(&self) -> &str {
"https://claude.ai/oauth/authorize"
}
fn token_url(&self) -> &str {
"https://console.anthropic.com/v1/oauth/token"
}
fn redirect_uri(&self) -> &str {
"https://console.anthropic.com/oauth/code/callback"
}
fn scopes(&self) -> &str {
"org:create_api_key user:profile user:inference"
}
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
vec![("code", "true")]
}
fn extra_token_headers(&self) -> Vec<(&str, &str)> {
vec![("anthropic-beta", BETA_HEADER)]
}
fn extra_request_headers(&self) -> Vec<(&str, &str)> {
vec![("anthropic-beta", BETA_HEADER)]
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ impl CohereClient {
config_get_fn!(api_key, get_api_key); config_get_fn!(api_key, get_api_key);
config_get_fn!(api_base, get_api_base); config_get_fn!(api_base, get_api_base);
pub const PROMPTS: [PromptAction<'static>; 1] = [("api_key", "API Key", None, true)]; create_client_config!([("api_key", "API Key", None, true)]);
} }
impl_client_trait!( impl_client_trait!(
+5 -9
View File
@@ -47,6 +47,10 @@ pub trait Client: Sync + Send {
fn model(&self) -> &Model; fn model(&self) -> &Model;
fn supports_oauth(&self) -> bool {
false
}
fn build_client(&self) -> Result<ReqwestClient> { fn build_client(&self) -> Result<ReqwestClient> {
let mut builder = ReqwestClient::builder(); let mut builder = ReqwestClient::builder();
let extra = self.extra_config(); let extra = self.extra_config();
@@ -489,14 +493,6 @@ pub async fn call_chat_completions_streaming(
} }
} }
pub fn noop_prepare_embeddings<T>(_client: &T, _data: &EmbeddingsData) -> Result<RequestData> {
bail!("The client doesn't support embeddings api")
}
pub async fn noop_embeddings(_builder: RequestBuilder, _model: &Model) -> Result<EmbeddingsOutput> {
bail!("The client doesn't support embeddings api")
}
pub fn noop_prepare_rerank<T>(_client: &T, _data: &RerankData) -> Result<RequestData> { pub fn noop_prepare_rerank<T>(_client: &T, _data: &RerankData) -> Result<RequestData> {
bail!("The client doesn't support rerank api") bail!("The client doesn't support rerank api")
} }
@@ -554,7 +550,7 @@ pub fn json_str_from_map<'a>(
map.get(field_name).and_then(|v| v.as_str()) map.get(field_name).and_then(|v| v.as_str())
} }
async fn set_client_models_config(client_config: &mut Value, client: &str) -> Result<String> { pub async fn set_client_models_config(client_config: &mut Value, client: &str) -> Result<String> {
if let Some(provider) = ALL_PROVIDER_MODELS.iter().find(|v| v.provider == client) { if let Some(provider) = ALL_PROVIDER_MODELS.iter().find(|v| v.provider == client) {
let models: Vec<String> = provider let models: Vec<String> = provider
.models .models
+120 -35
View File
@@ -1,10 +1,13 @@
use super::access_token::get_access_token;
use super::gemini_oauth::GeminiOAuthProvider;
use super::oauth;
use super::vertexai::*; use super::vertexai::*;
use super::*; use super::*;
use anyhow::{Context, Result}; use anyhow::{Context, Result, bail};
use reqwest::RequestBuilder; use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
const API_BASE: &str = "https://generativelanguage.googleapis.com/v1beta"; const API_BASE: &str = "https://generativelanguage.googleapis.com/v1beta";
@@ -13,6 +16,7 @@ pub struct GeminiConfig {
pub name: Option<String>, pub name: Option<String>,
pub api_key: Option<String>, pub api_key: Option<String>,
pub api_base: Option<String>, pub api_base: Option<String>,
pub auth: Option<String>,
#[serde(default)] #[serde(default)]
pub models: Vec<ModelData>, pub models: Vec<ModelData>,
pub patch: Option<RequestPatch>, pub patch: Option<RequestPatch>,
@@ -23,25 +27,64 @@ impl GeminiClient {
config_get_fn!(api_key, get_api_key); config_get_fn!(api_key, get_api_key);
config_get_fn!(api_base, get_api_base); config_get_fn!(api_base, get_api_base);
pub const PROMPTS: [PromptAction<'static>; 1] = [("api_key", "API Key", None, true)]; create_oauth_supported_client_config!();
} }
impl_client_trait!( #[async_trait::async_trait]
GeminiClient, impl Client for GeminiClient {
( client_common_fns!();
prepare_chat_completions,
gemini_chat_completions,
gemini_chat_completions_streaming
),
(prepare_embeddings, embeddings),
(noop_prepare_rerank, noop_rerank),
);
fn prepare_chat_completions( fn supports_oauth(&self) -> bool {
self.config.auth.as_deref() == Some("oauth")
}
async fn chat_completions_inner(
&self,
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
let request_data = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
gemini_chat_completions(builder, self.model()).await
}
async fn chat_completions_streaming_inner(
&self,
client: &ReqwestClient,
handler: &mut SseHandler,
data: ChatCompletionsData,
) -> Result<()> {
let request_data = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
gemini_chat_completions_streaming(builder, handler, self.model()).await
}
async fn embeddings_inner(
&self,
client: &ReqwestClient,
data: &EmbeddingsData,
) -> Result<EmbeddingsOutput> {
let request_data = prepare_embeddings(self, client, data).await?;
let builder = self.request_builder(client, request_data);
embeddings(builder, self.model()).await
}
async fn rerank_inner(
&self,
client: &ReqwestClient,
data: &RerankData,
) -> Result<RerankOutput> {
let request_data = noop_prepare_rerank(self, data)?;
let builder = self.request_builder(client, request_data);
noop_rerank(builder, self.model()).await
}
}
async fn prepare_chat_completions(
self_: &GeminiClient, self_: &GeminiClient,
client: &ReqwestClient,
data: ChatCompletionsData, data: ChatCompletionsData,
) -> Result<RequestData> { ) -> Result<RequestData> {
let api_key = self_.get_api_key()?;
let api_base = self_ let api_base = self_
.get_api_base() .get_api_base()
.unwrap_or_else(|_| API_BASE.to_string()); .unwrap_or_else(|_| API_BASE.to_string());
@@ -59,26 +102,61 @@ fn prepare_chat_completions(
); );
let body = gemini_build_chat_completions_body(data, &self_.model)?; let body = gemini_build_chat_completions_body(data, &self_.model)?;
let mut request_data = RequestData::new(url, body); let mut request_data = RequestData::new(url, body);
request_data.header("x-goog-api-key", api_key); let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if uses_oauth {
let provider = GeminiOAuthProvider;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
if !ready {
bail!(
"OAuth configured but no tokens found for '{}'. Run: 'loki --authenticate {}' or '.authenticate' in the REPL",
self_.name(),
self_.name()
);
}
let token = get_access_token(self_.name())?;
request_data.bearer_auth(token);
} else if let Ok(api_key) = self_.get_api_key() {
request_data.header("x-goog-api-key", api_key);
} else {
bail!(
"No authentication configured for '{}'. Set `api_key` or use `auth: oauth` with `loki --authenticate {}`.",
self_.name(),
self_.name()
);
}
Ok(request_data) Ok(request_data)
} }
fn prepare_embeddings(self_: &GeminiClient, data: &EmbeddingsData) -> Result<RequestData> { async fn prepare_embeddings(
let api_key = self_.get_api_key()?; self_: &GeminiClient,
client: &ReqwestClient,
data: &EmbeddingsData,
) -> Result<RequestData> {
let api_base = self_ let api_base = self_
.get_api_base() .get_api_base()
.unwrap_or_else(|_| API_BASE.to_string()); .unwrap_or_else(|_| API_BASE.to_string());
let url = format!( let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
"{}/models/{}:batchEmbedContents?key={}",
api_base.trim_end_matches('/'), let url = if uses_oauth {
self_.model.real_name(), format!(
api_key "{}/models/{}:batchEmbedContents",
); api_base.trim_end_matches('/'),
self_.model.real_name(),
)
} else {
let api_key = self_.get_api_key()?;
format!(
"{}/models/{}:batchEmbedContents?key={}",
api_base.trim_end_matches('/'),
self_.model.real_name(),
api_key
)
};
let model_id = format!("models/{}", self_.model.real_name()); let model_id = format!("models/{}", self_.model.real_name());
@@ -89,21 +167,28 @@ fn prepare_embeddings(self_: &GeminiClient, data: &EmbeddingsData) -> Result<Req
json!({ json!({
"model": model_id, "model": model_id,
"content": { "content": {
"parts": [ "parts": [{ "text": text }]
{
"text": text
}
]
}, },
}) })
}) })
.collect(); .collect();
let body = json!({ let body = json!({ "requests": requests });
"requests": requests, let mut request_data = RequestData::new(url, body);
});
let request_data = RequestData::new(url, body); if uses_oauth {
let provider = GeminiOAuthProvider;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
if !ready {
bail!(
"OAuth configured but no tokens found for '{}'. Run: 'loki --authenticate {}' or '.authenticate' in the REPL",
self_.name(),
self_.name()
);
}
let token = get_access_token(self_.name())?;
request_data.bearer_auth(token);
}
Ok(request_data) Ok(request_data)
} }
+49
View File
@@ -0,0 +1,49 @@
use super::oauth::{OAuthProvider, TokenRequestFormat};
pub struct GeminiOAuthProvider;
const GEMINI_CLIENT_ID: &str =
"50826443741-upqcebrs4gctqht1f08ku46qlbirkdsj.apps.googleusercontent.com";
const GEMINI_CLIENT_SECRET: &str = "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";
impl OAuthProvider for GeminiOAuthProvider {
fn provider_name(&self) -> &str {
"gemini"
}
fn client_id(&self) -> &str {
GEMINI_CLIENT_ID
}
fn authorize_url(&self) -> &str {
"https://accounts.google.com/o/oauth2/v2/auth"
}
fn token_url(&self) -> &str {
"https://oauth2.googleapis.com/token"
}
fn redirect_uri(&self) -> &str {
""
}
fn scopes(&self) -> &str {
"https://www.googleapis.com/auth/generative-language.peruserquota https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/userinfo.email"
}
fn client_secret(&self) -> Option<&str> {
Some(GEMINI_CLIENT_SECRET)
}
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
vec![("access_type", "offline"), ("prompt", "consent")]
}
fn token_request_format(&self) -> TokenRequestFormat {
TokenRequestFormat::FormUrlEncoded
}
fn uses_localhost_redirect(&self) -> bool {
true
}
}
+39 -1
View File
@@ -90,7 +90,7 @@ macro_rules! register_client {
pub async fn create_client_config(client: &str, vault: &$crate::vault::Vault) -> anyhow::Result<(String, serde_json::Value)> { pub async fn create_client_config(client: &str, vault: &$crate::vault::Vault) -> anyhow::Result<(String, serde_json::Value)> {
$( $(
if client == $client::NAME && client != $crate::client::OpenAICompatibleClient::NAME { if client == $client::NAME && client != $crate::client::OpenAICompatibleClient::NAME {
return create_config(&$client::PROMPTS, $client::NAME, vault).await return $client::create_client_config(vault).await
} }
)+ )+
if let Some(ret) = create_openai_compatible_client_config(client).await? { if let Some(ret) = create_openai_compatible_client_config(client).await? {
@@ -218,6 +218,44 @@ macro_rules! impl_client_trait {
}; };
} }
#[macro_export]
macro_rules! create_client_config {
($prompts:expr) => {
pub async fn create_client_config(
vault: &$crate::vault::Vault,
) -> anyhow::Result<(String, serde_json::Value)> {
$crate::client::create_config(&$prompts, Self::NAME, vault).await
}
};
}
#[macro_export]
macro_rules! create_oauth_supported_client_config {
() => {
pub async fn create_client_config(vault: &$crate::vault::Vault) -> anyhow::Result<(String, serde_json::Value)> {
let mut config = serde_json::json!({ "type": Self::NAME });
let auth_method = inquire::Select::new(
"Authentication method:",
vec!["API Key", "OAuth"],
)
.prompt()?;
if auth_method == "API Key" {
let env_name = format!("{}_API_KEY", Self::NAME).to_ascii_uppercase();
vault.add_secret(&env_name)?;
config["api_key"] = format!("{{{{{env_name}}}}}").into();
} else {
config["auth"] = "oauth".into();
}
let model = $crate::client::set_client_models_config(&mut config, Self::NAME).await?;
let clients = json!(vec![config]);
Ok((model, clients))
}
}
}
#[macro_export] #[macro_export]
macro_rules! config_get_fn { macro_rules! config_get_fn {
($field_name:ident, $fn_name:ident) => { ($field_name:ident, $fn_name:ident) => {
+3
View File
@@ -1,6 +1,9 @@
mod access_token; mod access_token;
mod claude_oauth;
mod common; mod common;
mod gemini_oauth;
mod message; mod message;
pub mod oauth;
#[macro_use] #[macro_use]
mod macros; mod macros;
mod model; mod model;
+4
View File
@@ -177,6 +177,10 @@ impl Model {
self.data.max_output_tokens self.data.max_output_tokens
} }
pub fn supports_function_calling(&self) -> bool {
self.data.supports_function_calling
}
pub fn no_stream(&self) -> bool { pub fn no_stream(&self) -> bool {
self.data.no_stream self.data.no_stream
} }
+429
View File
@@ -0,0 +1,429 @@
use super::ClientConfig;
use super::access_token::{is_valid_access_token, set_access_token};
use crate::config::Config;
use anyhow::{Result, anyhow, bail};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
use inquire::Text;
use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use url::Url;
use uuid::Uuid;
pub enum TokenRequestFormat {
Json,
FormUrlEncoded,
}
pub trait OAuthProvider: Send + Sync {
fn provider_name(&self) -> &str;
fn client_id(&self) -> &str;
fn authorize_url(&self) -> &str;
fn token_url(&self) -> &str;
fn redirect_uri(&self) -> &str;
fn scopes(&self) -> &str;
fn client_secret(&self) -> Option<&str> {
None
}
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
vec![]
}
fn token_request_format(&self) -> TokenRequestFormat {
TokenRequestFormat::Json
}
fn uses_localhost_redirect(&self) -> bool {
false
}
fn extra_token_headers(&self) -> Vec<(&str, &str)> {
vec![]
}
fn extra_request_headers(&self) -> Vec<(&str, &str)> {
vec![]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthTokens {
pub access_token: String,
pub refresh_token: String,
pub expires_at: i64,
}
pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) -> Result<()> {
let random_bytes: [u8; 32] = rand::random::<[u8; 32]>();
let code_verifier = URL_SAFE_NO_PAD.encode(random_bytes);
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
let state = Uuid::new_v4().to_string();
let redirect_uri = if provider.uses_localhost_redirect() {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let uri = format!("http://127.0.0.1:{port}/callback");
drop(listener);
uri
} else {
provider.redirect_uri().to_string()
};
let encoded_scopes = urlencoding::encode(provider.scopes());
let encoded_redirect = urlencoding::encode(&redirect_uri);
let mut authorize_url = format!(
"{}?client_id={}&response_type=code&scope={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state={}",
provider.authorize_url(),
provider.client_id(),
encoded_scopes,
encoded_redirect,
code_challenge,
state
);
for (key, value) in provider.extra_authorize_params() {
authorize_url.push_str(&format!(
"&{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
));
}
println!(
"\nOpen this URL to authenticate with {} (client '{}'):\n",
provider.provider_name(),
client_name
);
println!(" {authorize_url}\n");
let _ = open::that(&authorize_url);
let (code, returned_state) = if provider.uses_localhost_redirect() {
listen_for_oauth_callback(&redirect_uri)?
} else {
let input = Text::new("Paste the authorization code:").prompt()?;
let parts: Vec<&str> = input.splitn(2, '#').collect();
if parts.len() != 2 {
bail!("Invalid authorization code format. Expected format: <code>#<state>");
}
(parts[0].to_string(), parts[1].to_string())
};
if returned_state != state {
bail!(
"OAuth state mismatch: expected '{state}', got '{returned_state}'. \
This may indicate a CSRF attack or a stale authorization attempt."
);
}
let client = ReqwestClient::new();
let request = build_token_request(
&client,
provider,
&[
("grant_type", "authorization_code"),
("client_id", provider.client_id()),
("code", &code),
("code_verifier", &code_verifier),
("redirect_uri", &redirect_uri),
("state", &state),
],
);
let response: Value = request.send().await?.json().await?;
let access_token = response["access_token"]
.as_str()
.ok_or_else(|| anyhow!("Missing access_token in response: {response}"))?
.to_string();
let refresh_token = response["refresh_token"]
.as_str()
.ok_or_else(|| anyhow!("Missing refresh_token in response: {response}"))?
.to_string();
let expires_in = response["expires_in"]
.as_i64()
.ok_or_else(|| anyhow!("Missing expires_in in response: {response}"))?;
let expires_at = Utc::now().timestamp() + expires_in;
let tokens = OAuthTokens {
access_token,
refresh_token,
expires_at,
};
save_oauth_tokens(client_name, &tokens)?;
println!(
"Successfully authenticated client '{}' with {} via OAuth. Tokens saved.",
client_name,
provider.provider_name()
);
Ok(())
}
pub fn load_oauth_tokens(client_name: &str) -> Option<OAuthTokens> {
let path = Config::token_file(client_name);
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_oauth_tokens(client_name: &str, tokens: &OAuthTokens) -> Result<()> {
let path = Config::token_file(client_name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(tokens)?;
fs::write(path, json)?;
Ok(())
}
pub async fn refresh_oauth_token(
client: &ReqwestClient,
provider: &impl OAuthProvider,
client_name: &str,
tokens: &OAuthTokens,
) -> Result<OAuthTokens> {
let request = build_token_request(
client,
provider,
&[
("grant_type", "refresh_token"),
("client_id", provider.client_id()),
("refresh_token", &tokens.refresh_token),
],
);
let response: Value = request.send().await?.json().await?;
let access_token = response["access_token"]
.as_str()
.ok_or_else(|| anyhow!("Missing access_token in refresh response: {response}"))?
.to_string();
let refresh_token = response["refresh_token"]
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| tokens.refresh_token.clone());
let expires_in = response["expires_in"]
.as_i64()
.ok_or_else(|| anyhow!("Missing expires_in in refresh response: {response}"))?;
let expires_at = Utc::now().timestamp() + expires_in;
let new_tokens = OAuthTokens {
access_token,
refresh_token,
expires_at,
};
save_oauth_tokens(client_name, &new_tokens)?;
Ok(new_tokens)
}
pub async fn prepare_oauth_access_token(
client: &ReqwestClient,
provider: &impl OAuthProvider,
client_name: &str,
) -> Result<bool> {
if is_valid_access_token(client_name) {
return Ok(true);
}
let tokens = match load_oauth_tokens(client_name) {
Some(t) => t,
None => return Ok(false),
};
let tokens = if Utc::now().timestamp() >= tokens.expires_at {
refresh_oauth_token(client, provider, client_name, &tokens).await?
} else {
tokens
};
set_access_token(client_name, tokens.access_token.clone(), tokens.expires_at);
Ok(true)
}
fn build_token_request(
client: &ReqwestClient,
provider: &(impl OAuthProvider + ?Sized),
params: &[(&str, &str)],
) -> RequestBuilder {
let mut request = match provider.token_request_format() {
TokenRequestFormat::Json => {
let body: serde_json::Map<String, Value> = params
.iter()
.map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
.collect();
if let Some(secret) = provider.client_secret() {
let mut body = body;
body.insert(
"client_secret".to_string(),
Value::String(secret.to_string()),
);
client.post(provider.token_url()).json(&body)
} else {
client.post(provider.token_url()).json(&body)
}
}
TokenRequestFormat::FormUrlEncoded => {
let mut form: HashMap<String, String> = params
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
if let Some(secret) = provider.client_secret() {
form.insert("client_secret".to_string(), secret.to_string());
}
client.post(provider.token_url()).form(&form)
}
};
for (key, value) in provider.extra_token_headers() {
request = request.header(key, value);
}
request
}
fn listen_for_oauth_callback(redirect_uri: &str) -> Result<(String, String)> {
let url: Url = redirect_uri.parse()?;
let host = url.host_str().unwrap_or("127.0.0.1");
let port = url
.port()
.ok_or_else(|| anyhow!("No port in redirect URI"))?;
let path = url.path();
println!("Waiting for OAuth callback on {redirect_uri} ...\n");
let listener = TcpListener::bind(format!("{host}:{port}"))?;
let (mut stream, _) = listener.accept()?;
let mut reader = BufReader::new(&stream);
let mut request_line = String::new();
reader.read_line(&mut request_line)?;
let request_path = request_line
.split_whitespace()
.nth(1)
.ok_or_else(|| anyhow!("Malformed HTTP request from OAuth callback"))?;
let full_url = format!("http://{host}:{port}{request_path}");
let parsed: Url = full_url.parse()?;
if !parsed.path().starts_with(path) {
bail!("Unexpected callback path: {}", parsed.path());
}
let code = parsed
.query_pairs()
.find(|(k, _)| k == "code")
.map(|(_, v)| v.to_string())
.ok_or_else(|| {
let error = parsed
.query_pairs()
.find(|(k, _)| k == "error")
.map(|(_, v)| v.to_string())
.unwrap_or_else(|| "unknown".to_string());
anyhow!("OAuth callback returned error: {error}")
})?;
let returned_state = parsed
.query_pairs()
.find(|(k, _)| k == "state")
.map(|(_, v)| v.to_string())
.ok_or_else(|| anyhow!("Missing state parameter in OAuth callback"))?;
let response_body = "<html><body><h2>Authentication successful!</h2><p>You can close this tab and return to your terminal.</p></body></html>";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
stream.write_all(response.as_bytes())?;
Ok((code, returned_state))
}
pub fn get_oauth_provider(provider_type: &str) -> Option<Box<dyn OAuthProvider>> {
match provider_type {
"claude" => Some(Box::new(super::claude_oauth::ClaudeOAuthProvider)),
"gemini" => Some(Box::new(super::gemini_oauth::GeminiOAuthProvider)),
_ => None,
}
}
pub fn resolve_provider_type(client_name: &str, clients: &[ClientConfig]) -> Option<&'static str> {
for client_config in clients {
let (config_name, provider_type, auth) = client_config_info(client_config);
if config_name == client_name {
if auth == Some("oauth") && get_oauth_provider(provider_type).is_some() {
return Some(provider_type);
}
return None;
}
}
None
}
pub fn list_oauth_capable_clients(clients: &[ClientConfig]) -> Vec<String> {
clients
.iter()
.filter_map(|client_config| {
let (name, provider_type, auth) = client_config_info(client_config);
if auth == Some("oauth") && get_oauth_provider(provider_type).is_some() {
Some(name.to_string())
} else {
None
}
})
.collect()
}
fn client_config_info(client_config: &ClientConfig) -> (&str, &'static str, Option<&str>) {
match client_config {
ClientConfig::ClaudeConfig(c) => (
c.name.as_deref().unwrap_or("claude"),
"claude",
c.auth.as_deref(),
),
ClientConfig::OpenAIConfig(c) => (c.name.as_deref().unwrap_or("openai"), "openai", None),
ClientConfig::OpenAICompatibleConfig(c) => (
c.name.as_deref().unwrap_or("openai-compatible"),
"openai-compatible",
None,
),
ClientConfig::GeminiConfig(c) => (
c.name.as_deref().unwrap_or("gemini"),
"gemini",
c.auth.as_deref(),
),
ClientConfig::CohereConfig(c) => (c.name.as_deref().unwrap_or("cohere"), "cohere", None),
ClientConfig::AzureOpenAIConfig(c) => (
c.name.as_deref().unwrap_or("azure-openai"),
"azure-openai",
None,
),
ClientConfig::VertexAIConfig(c) => {
(c.name.as_deref().unwrap_or("vertexai"), "vertexai", None)
}
ClientConfig::BedrockConfig(c) => (c.name.as_deref().unwrap_or("bedrock"), "bedrock", None),
ClientConfig::Unknown => ("unknown", "unknown", None),
}
}
+6 -4
View File
@@ -2,10 +2,10 @@ use super::*;
use crate::utils::strip_think_tag; use crate::utils::strip_think_tag;
use anyhow::{bail, Context, Result}; use anyhow::{Context, Result, bail};
use reqwest::RequestBuilder; use reqwest::RequestBuilder;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
const API_BASE: &str = "https://api.openai.com/v1"; const API_BASE: &str = "https://api.openai.com/v1";
@@ -25,7 +25,7 @@ impl OpenAIClient {
config_get_fn!(api_key, get_api_key); config_get_fn!(api_key, get_api_key);
config_get_fn!(api_base, get_api_base); config_get_fn!(api_base, get_api_base);
pub const PROMPTS: [PromptAction<'static>; 1] = [("api_key", "API Key", None, true)]; create_client_config!([("api_key", "API Key", None, true)]);
} }
impl_client_trait!( impl_client_trait!(
@@ -114,7 +114,9 @@ pub async fn openai_chat_completions_streaming(
function_arguments = String::from("{}"); function_arguments = String::from("{}");
} }
let arguments: Value = function_arguments.parse().with_context(|| { let arguments: Value = function_arguments.parse().with_context(|| {
format!("Tool call '{function_name}' has non-JSON arguments '{function_arguments}'") format!(
"Tool call '{function_name}' has non-JSON arguments '{function_arguments}'"
)
})?; })?;
handler.tool_call(ToolCall::new( handler.tool_call(ToolCall::new(
function_name.clone(), function_name.clone(),
+1 -1
View File
@@ -21,7 +21,7 @@ impl OpenAICompatibleClient {
config_get_fn!(api_base, get_api_base); config_get_fn!(api_base, get_api_base);
config_get_fn!(api_key, get_api_key); config_get_fn!(api_key, get_api_key);
pub const PROMPTS: [PromptAction<'static>; 0] = []; create_client_config!([]);
} }
impl_client_trait!( impl_client_trait!(
+24 -16
View File
@@ -3,11 +3,11 @@ use super::claude::*;
use super::openai::*; use super::openai::*;
use super::*; use super::*;
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{Context, Result, anyhow, bail};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use reqwest::{Client as ReqwestClient, RequestBuilder}; use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::{path::PathBuf, str::FromStr}; use std::{path::PathBuf, str::FromStr};
#[derive(Debug, Clone, Deserialize, Default)] #[derive(Debug, Clone, Deserialize, Default)]
@@ -26,10 +26,10 @@ impl VertexAIClient {
config_get_fn!(project_id, get_project_id); config_get_fn!(project_id, get_project_id);
config_get_fn!(location, get_location); config_get_fn!(location, get_location);
pub const PROMPTS: [PromptAction<'static>; 2] = [ create_client_config!([
("project_id", "Project ID", None, false), ("project_id", "Project ID", None, false),
("location", "Location", None, false), ("location", "Location", None, false),
]; ]);
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -99,9 +99,13 @@ fn prepare_chat_completions(
let access_token = get_access_token(self_.name())?; let access_token = get_access_token(self_.name())?;
let base_url = if location == "global" { let base_url = if location == "global" {
format!("https://aiplatform.googleapis.com/v1/projects/{project_id}/locations/global/publishers") format!(
"https://aiplatform.googleapis.com/v1/projects/{project_id}/locations/global/publishers"
)
} else { } else {
format!("https://{location}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{location}/publishers") format!(
"https://{location}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{location}/publishers"
)
}; };
let model_name = self_.model.real_name(); let model_name = self_.model.real_name();
@@ -158,9 +162,13 @@ fn prepare_embeddings(self_: &VertexAIClient, data: &EmbeddingsData) -> Result<R
let access_token = get_access_token(self_.name())?; let access_token = get_access_token(self_.name())?;
let base_url = if location == "global" { let base_url = if location == "global" {
format!("https://aiplatform.googleapis.com/v1/projects/{project_id}/locations/global/publishers") format!(
"https://aiplatform.googleapis.com/v1/projects/{project_id}/locations/global/publishers"
)
} else { } else {
format!("https://{location}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{location}/publishers") format!(
"https://{location}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{location}/publishers"
)
}; };
let url = format!( let url = format!(
"{base_url}/google/models/{}:predict", "{base_url}/google/models/{}:predict",
@@ -220,12 +228,12 @@ pub async fn gemini_chat_completions_streaming(
part["functionCall"]["args"].as_object(), part["functionCall"]["args"].as_object(),
) { ) {
let thought_signature = part["thoughtSignature"] let thought_signature = part["thoughtSignature"]
.as_str() .as_str()
.or_else(|| part["thought_signature"].as_str()) .or_else(|| part["thought_signature"].as_str())
.map(|s| s.to_string()); .map(|s| s.to_string());
handler.tool_call( handler.tool_call(
ToolCall::new(name.to_string(), json!(args), None) ToolCall::new(name.to_string(), json!(args), None)
.with_thought_signature(thought_signature), .with_thought_signature(thought_signature),
)?; )?;
} }
} }
@@ -288,12 +296,12 @@ fn gemini_extract_chat_completions_text(data: &Value) -> Result<ChatCompletionsO
part["functionCall"]["args"].as_object(), part["functionCall"]["args"].as_object(),
) { ) {
let thought_signature = part["thoughtSignature"] let thought_signature = part["thoughtSignature"]
.as_str() .as_str()
.or_else(|| part["thought_signature"].as_str()) .or_else(|| part["thought_signature"].as_str())
.map(|s| s.to_string()); .map(|s| s.to_string());
tool_calls.push( tool_calls.push(
ToolCall::new(name.to_string(), json!(args), None) ToolCall::new(name.to_string(), json!(args), None)
.with_thought_signature(thought_signature), .with_thought_signature(thought_signature),
); );
} }
} }
+5
View File
@@ -476,6 +476,11 @@ impl Agent {
self.todo_list.mark_done(id) self.todo_list.mark_done(id)
} }
pub fn clear_todo_list(&mut self) {
self.todo_list.clear();
self.reset_continuation();
}
pub fn continuation_prompt(&self) -> String { pub fn continuation_prompt(&self) -> String {
self.config.continuation_prompt.clone().unwrap_or_else(|| { self.config.continuation_prompt.clone().unwrap_or_else(|| {
formatdoc! {" formatdoc! {"
+10 -5
View File
@@ -239,12 +239,17 @@ impl Input {
patch_messages(&mut messages, model); patch_messages(&mut messages, model);
model.guard_max_input_tokens(&messages)?; model.guard_max_input_tokens(&messages)?;
let (temperature, top_p) = (self.role().temperature(), self.role().top_p()); let (temperature, top_p) = (self.role().temperature(), self.role().top_p());
let functions = self.config.read().select_functions(self.role()); let functions = if model.supports_function_calling() {
if let Some(vec) = &functions { let fns = self.config.read().select_functions(self.role());
for def in vec { if let Some(vec) = &fns {
debug!("Function definition: {:?}", def.name); for def in vec {
debug!("Function definition: {:?}", def.name);
}
} }
} fns
} else {
None
};
Ok(ChatCompletionsData { Ok(ChatCompletionsData {
messages, messages,
temperature, temperature,
+14
View File
@@ -428,6 +428,14 @@ impl Config {
base_dir.join(env!("CARGO_CRATE_NAME")) base_dir.join(env!("CARGO_CRATE_NAME"))
} }
pub fn oauth_tokens_path() -> PathBuf {
Self::cache_path().join("oauth")
}
pub fn token_file(client_name: &str) -> PathBuf {
Self::oauth_tokens_path().join(format!("{client_name}_oauth_tokens.json"))
}
pub fn log_path() -> PathBuf { pub fn log_path() -> PathBuf {
Config::cache_path().join(format!("{}.log", env!("CARGO_CRATE_NAME"))) Config::cache_path().join(format!("{}.log", env!("CARGO_CRATE_NAME")))
} }
@@ -1834,6 +1842,12 @@ impl Config {
bail!("Already in an agent, please run '.exit agent' first to exit the current agent."); bail!("Already in an agent, please run '.exit agent' first to exit the current agent.");
} }
let agent = Agent::init(config, agent_name, abort_signal.clone()).await?; let agent = Agent::init(config, agent_name, abort_signal.clone()).await?;
if !agent.model().supports_function_calling() {
eprintln!(
"Warning: The model '{}' does not support function calling. Agent tools (including todo, spawning, and user interaction) will not be available.",
agent.model().id()
);
}
let session = session_name.map(|v| v.to_string()).or_else(|| { let session = session_name.map(|v| v.to_string()).or_else(|| {
if config.read().macro_flag { if config.read().macro_flag {
None None
+2
View File
@@ -7,10 +7,12 @@ pub(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {"
- `todo__add`: Add individual tasks. Add all planned steps before starting work. - `todo__add`: Add individual tasks. Add all planned steps before starting work.
- `todo__done`: Mark a task done by id. Call this immediately after completing each step. - `todo__done`: Mark a task done by id. Call this immediately after completing each step.
- `todo__list`: Show the current todo list. - `todo__list`: Show the current todo list.
- `todo__clear`: Clear the entire todo list and reset the goal. Use when the user cancels or changes direction.
RULES: RULES:
- Always create a todo list before starting work. - Always create a todo list before starting work.
- Mark each task done as soon as you finish it; do not batch. - Mark each task done as soon as you finish it; do not batch.
- If the user cancels the current task or changes direction, call `todo__clear` immediately.
- If you stop with incomplete tasks, the system will automatically prompt you to continue." - If you stop with incomplete tasks, the system will automatically prompt you to continue."
}; };
+20
View File
@@ -67,6 +67,11 @@ impl TodoList {
self.todos.is_empty() self.todos.is_empty()
} }
pub fn clear(&mut self) {
self.goal.clear();
self.todos.clear();
}
pub fn render_for_model(&self) -> String { pub fn render_for_model(&self) -> String {
let mut lines = Vec::new(); let mut lines = Vec::new();
if !self.goal.is_empty() { if !self.goal.is_empty() {
@@ -149,6 +154,21 @@ mod tests {
assert!(rendered.contains("○ 2. Map")); assert!(rendered.contains("○ 2. Map"));
} }
#[test]
fn test_clear() {
let mut list = TodoList::new("Some goal");
list.add("Task 1");
list.add("Task 2");
list.mark_done(1);
assert!(!list.is_empty());
list.clear();
assert!(list.is_empty());
assert!(list.goal.is_empty());
assert_eq!(list.todos.len(), 0);
assert!(!list.has_incomplete());
}
#[test] #[test]
fn test_serialization_roundtrip() { fn test_serialization_roundtrip() {
let mut list = TodoList::new("Roundtrip"); let mut list = TodoList::new("Roundtrip");
+48 -7
View File
@@ -22,7 +22,7 @@ use serde_json::{Value, json};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::{Read, Write};
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
env, fs, io, env, fs, io,
@@ -1064,7 +1064,7 @@ impl ToolCall {
function_name.clone(), function_name.clone(),
function_name, function_name,
vec![], vec![],
Default::default(), agent.variable_envs(),
)) ))
} }
} }
@@ -1117,18 +1117,59 @@ pub fn run_llm_function(
#[cfg(windows)] #[cfg(windows)]
let cmd_name = polyfill_cmd_name(&cmd_name, &bin_dirs); let cmd_name = polyfill_cmd_name(&cmd_name, &bin_dirs);
let output = Command::new(&cmd_name) envs.insert("CLICOLOR_FORCE".into(), "1".into());
envs.insert("FORCE_COLOR".into(), "1".into());
let mut child = Command::new(&cmd_name)
.args(&cmd_args) .args(&cmd_args)
.envs(envs) .envs(envs)
.stdout(Stdio::inherit()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
.and_then(|child| child.wait_with_output())
.map_err(|err| anyhow!("Unable to run {command_name}, {err}"))?; .map_err(|err| anyhow!("Unable to run {command_name}, {err}"))?;
let exit_code = output.status.code().unwrap_or_default(); let stdout = child.stdout.take().expect("Failed to capture stdout");
let mut stderr = child.stderr.take().expect("Failed to capture stderr");
let stdout_thread = std::thread::spawn(move || {
let mut buffer = [0; 1024];
let mut reader = stdout;
let mut out = io::stdout();
while let Ok(n) = reader.read(&mut buffer) {
if n == 0 {
break;
}
let chunk = &buffer[0..n];
let mut last_pos = 0;
for (i, &byte) in chunk.iter().enumerate() {
if byte == b'\n' {
let _ = out.write_all(&chunk[last_pos..i]);
let _ = out.write_all(b"\r\n");
last_pos = i + 1;
}
}
if last_pos < n {
let _ = out.write_all(&chunk[last_pos..n]);
}
let _ = out.flush();
}
});
let stderr_thread = std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf);
buf
});
let status = child
.wait()
.map_err(|err| anyhow!("Unable to run {command_name}, {err}"))?;
let _ = stdout_thread.join();
let stderr_bytes = stderr_thread.join().unwrap_or_default();
let exit_code = status.code().unwrap_or_default();
if exit_code != 0 { if exit_code != 0 {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
if !stderr.is_empty() { if !stderr.is_empty() {
eprintln!("{stderr}"); eprintln!("{stderr}");
} }
+21
View File
@@ -76,6 +76,16 @@ pub fn todo_function_declarations() -> Vec<FunctionDeclaration> {
}, },
agent: false, agent: false,
}, },
FunctionDeclaration {
name: format!("{TODO_FUNCTION_PREFIX}clear"),
description: "Clear the entire todo list and reset the goal. Use when the current task has been canceled or invalidated.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::new()),
..Default::default()
},
agent: false,
},
] ]
} }
@@ -156,6 +166,17 @@ pub fn handle_todo_tool(config: &GlobalConfig, cmd_name: &str, args: &Value) ->
None => bail!("No active agent"), None => bail!("No active agent"),
} }
} }
"clear" => {
let mut cfg = config.write();
let agent = cfg.agent.as_mut();
match agent {
Some(agent) => {
agent.clear_todo_list();
Ok(json!({"status": "ok", "message": "Todo list cleared"}))
}
None => bail!("No active agent"),
}
}
_ => bail!("Unknown todo action: {action}"), _ => bail!("Unknown todo action: {action}"),
} }
} }
+9 -3
View File
@@ -12,6 +12,7 @@ use tokio::sync::oneshot;
pub const USER_FUNCTION_PREFIX: &str = "user__"; pub const USER_FUNCTION_PREFIX: &str = "user__";
const DEFAULT_ESCALATION_TIMEOUT_SECS: u64 = 300; const DEFAULT_ESCALATION_TIMEOUT_SECS: u64 = 300;
const CUSTOM_MULTI_CHOICE_ANSWER_OPTION: &str = "Other (custom)";
pub fn user_interaction_function_declarations() -> Vec<FunctionDeclaration> { pub fn user_interaction_function_declarations() -> Vec<FunctionDeclaration> {
vec![ vec![
@@ -151,9 +152,14 @@ fn handle_direct_ask(args: &Value) -> Result<Value> {
.get("question") .get("question")
.and_then(Value::as_str) .and_then(Value::as_str)
.ok_or_else(|| anyhow!("'question' is required"))?; .ok_or_else(|| anyhow!("'question' is required"))?;
let options = parse_options(args)?; let mut options = parse_options(args)?;
options.push(CUSTOM_MULTI_CHOICE_ANSWER_OPTION.to_string());
let answer = Select::new(question, options).prompt()?; let mut answer = Select::new(question, options).prompt()?;
if answer == CUSTOM_MULTI_CHOICE_ANSWER_OPTION {
answer = Text::new("Custom response:").prompt()?
}
Ok(json!({ "answer": answer })) Ok(json!({ "answer": answer }))
} }
@@ -175,7 +181,7 @@ fn handle_direct_input(args: &Value) -> Result<Value> {
.and_then(Value::as_str) .and_then(Value::as_str)
.ok_or_else(|| anyhow!("'question' is required"))?; .ok_or_else(|| anyhow!("'question' is required"))?;
let answer = Text::new(question).prompt()?; let answer = Text::new(&format!("{question}\nYour answer: ")).prompt()?;
Ok(json!({ "answer": answer })) Ok(json!({ "answer": answer }))
} }
+51 -4
View File
@@ -16,28 +16,30 @@ mod vault;
extern crate log; extern crate log;
use crate::client::{ use crate::client::{
ModelType, call_chat_completions, call_chat_completions_streaming, list_models, ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth,
}; };
use crate::config::{ use crate::config::{
Agent, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, GlobalConfig, Input, SHELL_ROLE, Agent, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, GlobalConfig, Input, SHELL_ROLE,
TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, list_agents, load_env_file, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, list_agents, load_env_file,
macro_execute, macro_execute,
}; };
use crate::render::render_error; use crate::render::{prompt_theme, render_error};
use crate::repl::Repl; use crate::repl::Repl;
use crate::utils::*; use crate::utils::*;
use crate::cli::Cli; use crate::cli::Cli;
use crate::vault::Vault; use crate::vault::Vault;
use anyhow::{Result, bail}; use anyhow::{Result, anyhow, bail};
use clap::{CommandFactory, Parser}; use clap::{CommandFactory, Parser};
use clap_complete::CompleteEnv; use clap_complete::CompleteEnv;
use inquire::Text; use client::ClientConfig;
use inquire::{Select, Text, set_global_render_config};
use log::LevelFilter; use log::LevelFilter;
use log4rs::append::console::ConsoleAppender; use log4rs::append::console::ConsoleAppender;
use log4rs::append::file::FileAppender; use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Logger, Root}; use log4rs::config::{Appender, Logger, Root};
use log4rs::encode::pattern::PatternEncoder; use log4rs::encode::pattern::PatternEncoder;
use oauth::OAuthProvider;
use parking_lot::RwLock; use parking_lot::RwLock;
use std::path::PathBuf; use std::path::PathBuf;
use std::{env, mem, process, sync::Arc}; use std::{env, mem, process, sync::Arc};
@@ -81,6 +83,13 @@ async fn main() -> Result<()> {
let log_path = setup_logger()?; let log_path = setup_logger()?;
if let Some(client_arg) = &cli.authenticate {
let config = Config::init_bare()?;
let (client_name, provider) = resolve_oauth_client(client_arg.as_deref(), &config.clients)?;
oauth::run_oauth_flow(&*provider, &client_name).await?;
return Ok(());
}
if vault_flags { if vault_flags {
return Vault::handle_vault_flags(cli, Config::init_bare()?); return Vault::handle_vault_flags(cli, Config::init_bare()?);
} }
@@ -97,6 +106,14 @@ async fn main() -> Result<()> {
) )
.await?, .await?,
)); ));
{
let cfg = config.read();
if cfg.highlight {
set_global_render_config(prompt_theme(cfg.render_options()?)?)
}
}
if let Err(err) = run(config, cli, text, abort_signal).await { if let Err(err) = run(config, cli, text, abort_signal).await {
render_error(err); render_error(err);
process::exit(1); process::exit(1);
@@ -504,3 +521,33 @@ fn init_console_logger(
.build(Root::builder().appender("console").build(root_log_level)) .build(Root::builder().appender("console").build(root_log_level))
.unwrap() .unwrap()
} }
fn resolve_oauth_client(
explicit: Option<&str>,
clients: &[ClientConfig],
) -> Result<(String, Box<dyn OAuthProvider>)> {
if let Some(name) = explicit {
let provider_type = oauth::resolve_provider_type(name, clients)
.ok_or_else(|| anyhow!("Client '{name}' not found or doesn't support OAuth"))?;
let provider = oauth::get_oauth_provider(provider_type).unwrap();
return Ok((name.to_string(), provider));
}
let candidates = oauth::list_oauth_capable_clients(clients);
match candidates.len() {
0 => bail!("No OAuth-capable clients configured."),
1 => {
let name = &candidates[0];
let provider_type = oauth::resolve_provider_type(name, clients).unwrap();
let provider = oauth::get_oauth_provider(provider_type).unwrap();
Ok((name.clone(), provider))
}
_ => {
let choice =
Select::new("Select a client to authenticate:", candidates.clone()).prompt()?;
let provider_type = oauth::resolve_provider_type(&choice, clients).unwrap();
let provider = oauth::get_oauth_provider(provider_type).unwrap();
Ok((choice, provider))
}
}
}
+76 -51
View File
@@ -158,27 +158,31 @@ impl McpRegistry {
} }
pub async fn reinit( pub async fn reinit(
registry: McpRegistry, mut registry: McpRegistry,
enabled_mcp_servers: Option<String>, enabled_mcp_servers: Option<String>,
abort_signal: AbortSignal, abort_signal: AbortSignal,
) -> Result<Self> { ) -> Result<Self> {
debug!("Reinitializing MCP registry"); debug!("Reinitializing MCP registry");
debug!("Stopping all MCP servers");
let mut new_registry = abortable_run_with_spinner( let desired_ids = registry.resolve_server_ids(enabled_mcp_servers.clone());
registry.stop_all_servers(), let desired_set: HashSet<String> = desired_ids.iter().cloned().collect();
"Stopping MCP servers",
debug!("Stopping unused MCP servers");
abortable_run_with_spinner(
registry.stop_unused_servers(&desired_set),
"Stopping unused MCP servers",
abort_signal.clone(), abort_signal.clone(),
) )
.await?; .await?;
abortable_run_with_spinner( abortable_run_with_spinner(
new_registry.start_select_mcp_servers(enabled_mcp_servers), registry.start_select_mcp_servers(enabled_mcp_servers),
"Loading MCP servers", "Loading MCP servers",
abort_signal, abort_signal,
) )
.await?; .await?;
Ok(new_registry) Ok(registry)
} }
async fn start_select_mcp_servers( async fn start_select_mcp_servers(
@@ -192,43 +196,30 @@ impl McpRegistry {
return Ok(()); return Ok(());
} }
if let Some(servers) = enabled_mcp_servers { let desired_ids = self.resolve_server_ids(enabled_mcp_servers);
debug!("Starting selected MCP servers: {:?}", servers); let ids_to_start: Vec<String> = desired_ids
let config = self .into_iter()
.config .filter(|id| !self.servers.contains_key(id))
.as_ref() .collect();
.with_context(|| "MCP Config not defined. Cannot start servers")?;
let mcp_servers = config.mcp_servers.clone();
let enabled_servers: HashSet<String> = if ids_to_start.is_empty() {
servers.split(',').map(|s| s.trim().to_string()).collect(); return Ok(());
let server_ids: Vec<String> = if servers == "all" { }
mcp_servers.into_keys().collect()
} else {
mcp_servers
.into_keys()
.filter(|id| enabled_servers.contains(id))
.collect()
};
let results: Vec<(String, Arc<_>, ServerCatalog)> = stream::iter( debug!("Starting selected MCP servers: {:?}", ids_to_start);
server_ids
.into_iter()
.map(|id| async { self.start_server(id).await }),
)
.buffer_unordered(num_cpus::get())
.try_collect()
.await?;
self.servers = results let results: Vec<(String, Arc<_>, ServerCatalog)> = stream::iter(
.clone() ids_to_start
.into_iter() .into_iter()
.map(|(id, server, _)| (id, server)) .map(|id| async { self.start_server(id).await }),
.collect(); )
self.catalogs = results .buffer_unordered(num_cpus::get())
.into_iter() .try_collect()
.map(|(id, _, catalog)| (id, catalog)) .await?;
.collect();
for (id, server, catalog) in results {
self.servers.insert(id.clone(), server);
self.catalogs.insert(id, catalog);
} }
Ok(()) Ok(())
@@ -309,19 +300,53 @@ impl McpRegistry {
Ok((id.to_string(), service, catalog)) Ok((id.to_string(), service, catalog))
} }
pub async fn stop_all_servers(mut self) -> Result<Self> { fn resolve_server_ids(&self, enabled_mcp_servers: Option<String>) -> Vec<String> {
for (id, server) in self.servers { if let Some(config) = &self.config
Arc::try_unwrap(server) && let Some(servers) = enabled_mcp_servers
.map_err(|_| anyhow!("Failed to unwrap Arc for MCP server: {id}"))? {
.cancel() if servers == "all" {
.await config.mcp_servers.keys().cloned().collect()
.with_context(|| format!("Failed to stop MCP server: {id}"))?; } else {
info!("Stopped MCP server: {id}"); let enabled_servers: HashSet<String> =
servers.split(',').map(|s| s.trim().to_string()).collect();
config
.mcp_servers
.keys()
.filter(|id| enabled_servers.contains(*id))
.cloned()
.collect()
}
} else {
vec![]
}
}
pub async fn stop_unused_servers(&mut self, keep_ids: &HashSet<String>) -> Result<()> {
let mut ids_to_remove = Vec::new();
for (id, _) in self.servers.iter() {
if !keep_ids.contains(id) {
ids_to_remove.push(id.clone());
}
} }
self.servers = HashMap::new(); for id in ids_to_remove {
if let Some(server) = self.servers.remove(&id) {
Ok(self) match Arc::try_unwrap(server) {
Ok(server_inner) => {
server_inner
.cancel()
.await
.with_context(|| format!("Failed to stop MCP server: {id}"))?;
info!("Stopped MCP server: {id}");
}
Err(_) => {
info!("Detaching from MCP server: {id} (still in use)");
}
}
self.catalogs.remove(&id);
}
}
Ok(())
} }
pub fn list_started_servers(&self) -> Vec<String> { pub fn list_started_servers(&self) -> Vec<String> {
+53
View File
@@ -0,0 +1,53 @@
use crate::render::RenderOptions;
use anyhow::Result;
use inquire::ui::{Attributes, Color, RenderConfig, StyleSheet};
use syntect::highlighting::{Highlighter, Theme};
use syntect::parsing::Scope;
const DEFAULT_INQUIRE_PROMPT_THEME: Color = Color::DarkYellow;
pub fn prompt_theme<'a>(render_options: RenderOptions) -> Result<RenderConfig<'a>> {
let theme = render_options.theme.as_ref();
let mut render_config = RenderConfig::default();
if let Some(theme_ref) = theme {
let prompt_color = resolve_foreground(theme_ref, "markup.heading")?
.unwrap_or(DEFAULT_INQUIRE_PROMPT_THEME);
render_config.prompt = StyleSheet::new()
.with_fg(prompt_color)
.with_attr(Attributes::BOLD);
render_config.selected_option = Some(
render_config
.selected_option
.unwrap_or(render_config.option)
.with_attr(
render_config
.selected_option
.unwrap_or(render_config.option)
.att
| Attributes::BOLD,
),
);
render_config.selected_checkbox = render_config
.selected_checkbox
.with_attr(render_config.selected_checkbox.style.att | Attributes::BOLD);
render_config.option = render_config
.option
.with_attr(render_config.option.att | Attributes::BOLD);
}
Ok(render_config)
}
fn resolve_foreground(theme: &Theme, scope_str: &str) -> Result<Option<Color>> {
let scope = Scope::new(scope_str)?;
let style_mod = Highlighter::new(theme).style_mod_for_stack(&[scope]);
let fg = style_mod.foreground.or(theme.settings.foreground);
Ok(fg.map(|c| Color::Rgb {
r: c.r,
g: c.g,
b: c.b,
}))
}
+3
View File
@@ -1,6 +1,9 @@
mod inquire;
mod markdown; mod markdown;
mod stream; mod stream;
pub use inquire::prompt_theme;
pub use self::markdown::{MarkdownRender, RenderOptions}; pub use self::markdown::{MarkdownRender, RenderOptions};
use self::stream::{markdown_stream, raw_stream}; use self::stream::{markdown_stream, raw_stream};
+45 -2
View File
@@ -6,7 +6,7 @@ use self::completer::ReplCompleter;
use self::highlighter::ReplHighlighter; use self::highlighter::ReplHighlighter;
use self::prompt::ReplPrompt; use self::prompt::ReplPrompt;
use crate::client::{call_chat_completions, call_chat_completions_streaming}; use crate::client::{call_chat_completions, call_chat_completions_streaming, init_client, oauth};
use crate::config::{ use crate::config::{
AgentVariables, AssertState, Config, GlobalConfig, Input, LastMessage, StateFlags, AgentVariables, AssertState, Config, GlobalConfig, Input, LastMessage, StateFlags,
macro_execute, macro_execute,
@@ -17,6 +17,7 @@ use crate::utils::{
}; };
use crate::mcp::McpRegistry; use crate::mcp::McpRegistry;
use crate::resolve_oauth_client;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use crossterm::cursor::SetCursorStyle; use crossterm::cursor::SetCursorStyle;
use fancy_regex::Regex; use fancy_regex::Regex;
@@ -32,10 +33,15 @@ use std::{env, mem, process};
const MENU_NAME: &str = "completion_menu"; const MENU_NAME: &str = "completion_menu";
static REPL_COMMANDS: LazyLock<[ReplCommand; 37]> = LazyLock::new(|| { static REPL_COMMANDS: LazyLock<[ReplCommand; 39]> = LazyLock::new(|| {
[ [
ReplCommand::new(".help", "Show this help guide", AssertState::pass()), ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
ReplCommand::new(".info", "Show system info", AssertState::pass()), ReplCommand::new(".info", "Show system info", AssertState::pass()),
ReplCommand::new(
".authenticate",
"Authenticate the current model client via OAuth (if configured)",
AssertState::pass(),
),
ReplCommand::new( ReplCommand::new(
".edit config", ".edit config",
"Modify configuration file", "Modify configuration file",
@@ -131,6 +137,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 37]> = LazyLock::new(|| {
"Leave agent", "Leave agent",
AssertState::True(StateFlags::AGENT), AssertState::True(StateFlags::AGENT),
), ),
ReplCommand::new(
".clear todo",
"Clear the todo list and stop auto-continuation",
AssertState::True(StateFlags::AGENT),
),
ReplCommand::new( ReplCommand::new(
".rag", ".rag",
"Initialize or access RAG", "Initialize or access RAG",
@@ -421,6 +432,19 @@ pub async fn run_repl_command(
} }
None => println!("Usage: .model <name>"), None => println!("Usage: .model <name>"),
}, },
".authenticate" => {
let current_model = config.read().current_model().clone();
let client = init_client(config, Some(current_model))?;
if !client.supports_oauth() {
bail!(
"Client '{}' doesn't either support OAuth or isn't configured to use it (i.e. uses an API key instead)",
client.name()
);
}
let clients = config.read().clients.clone();
let (client_name, provider) = resolve_oauth_client(Some(client.name()), &clients)?;
oauth::run_oauth_flow(&*provider, &client_name).await?;
}
".prompt" => match args { ".prompt" => match args {
Some(text) => { Some(text) => {
config.write().use_prompt(text)?; config.write().use_prompt(text)?;
@@ -785,6 +809,25 @@ pub async fn run_repl_command(
Some("messages") => { Some("messages") => {
bail!("Use '.empty session' instead"); bail!("Use '.empty session' instead");
} }
Some("todo") => {
let mut cfg = config.write();
match cfg.agent.as_mut() {
Some(agent) => {
if !agent.auto_continue_enabled() {
bail!(
"The todo system is not enabled for this agent. Set 'auto_continue: true' in the agent's config.yaml to enable it."
);
}
if agent.todo_list().is_empty() {
println!("Todo list is already empty.");
} else {
agent.clear_todo_list();
println!("Todo list cleared.");
}
}
None => bail!("No active agent"),
}
}
_ => unknown_command()?, _ => unknown_command()?,
}, },
".vault" => match split_first_arg(args) { ".vault" => match split_first_arg(args) {
+22 -11
View File
@@ -6,7 +6,6 @@ use gman::providers::local::LocalProvider;
use indoc::formatdoc; use indoc::formatdoc;
use inquire::validator::Validation; use inquire::validator::Validation;
use inquire::{Confirm, Password, PasswordDisplayMode, Text, min_length, required}; use inquire::{Confirm, Password, PasswordDisplayMode, Text, min_length, required};
use std::borrow::Cow;
use std::path::PathBuf; use std::path::PathBuf;
pub fn ensure_password_file_initialized(local_provider: &mut LocalProvider) -> Result<()> { pub fn ensure_password_file_initialized(local_provider: &mut LocalProvider) -> Result<()> {
@@ -166,18 +165,30 @@ pub fn create_vault_password_file(vault: &mut Vault) -> Result<()> {
Ok(()) Ok(())
} }
pub fn interpolate_secrets<'a>(content: &'a str, vault: &Vault) -> (Cow<'a, str>, Vec<String>) { pub fn interpolate_secrets(content: &str, vault: &Vault) -> (String, Vec<String>) {
let mut missing_secrets = vec![]; let mut missing_secrets = vec![];
let parsed_content = SECRET_RE.replace_all(content, |caps: &fancy_regex::Captures<'_>| { let parsed_content: String = content
let secret = vault.get_secret(caps[1].trim(), false); .lines()
match secret { .map(|line| {
Ok(s) => s, if line.trim_start().starts_with('#') {
Err(_) => { return line.to_string();
missing_secrets.push(caps[1].to_string());
"".to_string()
} }
}
}); SECRET_RE
.replace_all(line, |caps: &fancy_regex::Captures<'_>| {
let secret = vault.get_secret(caps[1].trim(), false);
match secret {
Ok(s) => s,
Err(_) => {
missing_secrets.push(caps[1].to_string());
"".to_string()
}
}
})
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
(parsed_content, missing_secrets) (parsed_content, missing_secrets)
} }