mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-28 00:05:50 +08:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bd5a6381e | ||
|
|
20578124c4 | ||
|
|
3b1f7220c5 | ||
|
|
0d052f831b | ||
|
|
3859627013 | ||
|
|
620ac1dbad | ||
|
|
06c743f15f | ||
|
|
f021597942 | ||
|
|
2d920ce915 | ||
|
|
80bfe94b77 | ||
|
|
5303de65ad | ||
|
|
50ff600466 | ||
|
|
4c3b10f087 | ||
|
|
6a797de094 | ||
|
|
687c6b03a4 | ||
|
|
7c38d78759 | ||
|
|
cb572811e1 | ||
|
|
fa0d53a969 | ||
|
|
910ac4e326 | ||
|
|
a25717aded | ||
|
|
7a1391fbc1 | ||
|
|
2944e02a09 | ||
|
|
cd01ffc5f3 | ||
|
|
4361e98998 | ||
|
|
ad65ea5a47 |
No files matched your search
@@ -0,0 +1,44 @@
|
|||||||
|
# Issue #3283 Triage Summary
|
||||||
|
|
||||||
|
**Issue:** docs: document OmniRoute OpenAI-compatible setup
|
||||||
|
**Opened by:** @diegosouzapw
|
||||||
|
**Status:** BLOCKED (waiting for clarification)
|
||||||
|
**Date:** 2026-10-13
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Investigation
|
||||||
|
1. Searched codebase for "OmniRoute" — **no matches found**
|
||||||
|
2. Searched documentation (docs/local-openai-compatible-providers.md, MODEL_COMPATIBILITY.md) — **no mentions**
|
||||||
|
3. Searched git history (commits, branches, PRs) — **no references**
|
||||||
|
4. Checked GitHub API (rate limited, but issue title confirmed)
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
- Issue #3283 is OPEN
|
||||||
|
- No labels assigned
|
||||||
|
- Title: "docs: document OmniRoute OpenAI-compatible setup"
|
||||||
|
- This is a documentation request for setting up an OpenAI-compatible provider
|
||||||
|
|
||||||
|
### Existing Documentation
|
||||||
|
The repository already has comprehensive documentation for OpenAI-compatible providers:
|
||||||
|
- `docs/local-openai-compatible-providers.md` — documents Ollama, llama.cpp, vLLM, and OpenRouter
|
||||||
|
- `docs/MODEL_COMPATIBILITY.md` — model-specific provider routing details
|
||||||
|
- `rust/crates/api/src/providers/openai_compat.rs` — provider implementation
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
**Action taken:** Comment added to issue asking for:
|
||||||
|
1. OmniRoute documentation/website link
|
||||||
|
2. OpenAI-compatible endpoint example (base URL, API key format)
|
||||||
|
3. Model ID examples
|
||||||
|
4. Any special configuration or known quirks
|
||||||
|
|
||||||
|
**Comment posted to:** https://github.com/ultraworkers/claw-code/issues/3283#issuecomment-6133484114
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**Cannot proceed without user input.** Once the user provides the necessary information about OmniRoute, the fix will involve:
|
||||||
|
1. Adding a new section to `docs/local-openai-compatible-providers.md`
|
||||||
|
2. Including setup example (environment variables, model names, basic smoke test)
|
||||||
|
3. Following the existing format used for Ollama, llama.cpp, vLLM sections
|
||||||
|
4. Creating a PR for review
|
||||||
@@ -130,6 +130,25 @@ claw --model "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit" prompt "Reply exactl
|
|||||||
|
|
||||||
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
|
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
|
||||||
|
|
||||||
|
## OmniRoute and other remote OpenAI-compatible gateways
|
||||||
|
|
||||||
|
[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a third-party OpenAI-compatible AI gateway that can route requests to multiple providers. To use any remote OpenAI-compatible gateway with Claw, set `OPENAI_BASE_URL` to the gateway's `/v1` endpoint and provide your API key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPENAI_BASE_URL="https://api.omniroute.ai/v1"
|
||||||
|
export OPENAI_API_KEY="your-omniroute-api-key"
|
||||||
|
claw --model "gpt-4" prompt "Reply exactly HELLO_WORLD_123"
|
||||||
|
```
|
||||||
|
|
||||||
|
To find the available model IDs on your gateway, use the `/models` endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "$OPENAI_BASE_URL/models" \
|
||||||
|
-H "Authorization: Bearer $OPENAI_API_KEY" | jq '.data[].id'
|
||||||
|
```
|
||||||
|
|
||||||
|
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
|
||||||
|
|
||||||
## Local skills install from disk
|
## Local skills install from disk
|
||||||
|
|
||||||
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
|
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
|
||||||
|
|||||||
+135
-43
@@ -349,6 +349,12 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -383,6 +389,17 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ciborium"
|
name = "ciborium"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -716,6 +733,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -999,15 +1022,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"rand_core 0.10.1",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1881,14 +1904,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.14"
|
version = "0.11.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.3",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.9.2",
|
"rand 0.10.3",
|
||||||
|
"rand_pcg",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -1925,9 +1949,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "5.3.0"
|
version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "radix_trie"
|
name = "radix_trie"
|
||||||
@@ -1946,18 +1970,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha 0.3.1",
|
"rand_chacha",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.2"
|
version = "0.10.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand_chacha 0.9.0",
|
"chacha20",
|
||||||
"rand_core 0.9.5",
|
"getrandom 0.4.3",
|
||||||
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1970,16 +1995,6 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_chacha"
|
|
||||||
version = "0.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
|
||||||
dependencies = [
|
|
||||||
"ppv-lite86",
|
|
||||||
"rand_core 0.9.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -1991,11 +2006,17 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.9.5"
|
version = "0.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.3.4",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2310,6 +2331,21 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sdk"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"tokio",
|
||||||
|
"tokio-test",
|
||||||
|
"tokio-tungstenite",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -2414,6 +2450,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -2585,7 +2632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -2706,6 +2753,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2 0.6.3",
|
"socket2 0.6.3",
|
||||||
@@ -2745,6 +2793,29 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-test"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12bc5db7778a8e08ee255090861e0e7aac55e784300077e7ed293c7be36168b2"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -2956,6 +3027,24 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.8.6",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
@@ -3004,6 +3093,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3016,6 +3111,18 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uuid"
|
||||||
|
version = "1.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"js-sys",
|
||||||
|
"serde_core",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -3053,15 +3160,6 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasip2"
|
|
||||||
version = "1.0.2+wasi-0.2.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
|
||||||
dependencies = [
|
|
||||||
"wit-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.116"
|
version = "0.2.116"
|
||||||
@@ -3370,12 +3468,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen"
|
|
||||||
version = "0.51.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
|
|||||||
@@ -13,7 +13,130 @@ pub enum ProviderClient {
|
|||||||
OpenAi(OpenAiCompatClient),
|
OpenAi(OpenAiCompatClient),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Provider selected explicitly by persisted/runtime configuration.
|
||||||
|
///
|
||||||
|
/// This is intentionally separate from `ProviderKind`: DashScope speaks the
|
||||||
|
/// OpenAI-compatible wire protocol but remains a distinct configured provider.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ProviderConfigKind {
|
||||||
|
Anthropic,
|
||||||
|
Xai,
|
||||||
|
OpenAi,
|
||||||
|
DashScope,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit provider configuration supplied by the runtime layer.
|
||||||
|
///
|
||||||
|
/// This keeps the API crate independent of runtime configuration types.
|
||||||
|
/// Credentials are redacted from Debug output.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ProviderConfig {
|
||||||
|
pub kind: ProviderConfigKind,
|
||||||
|
pub model: String,
|
||||||
|
pub api_key: Option<String>,
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ProviderConfig {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("ProviderConfig")
|
||||||
|
.field("kind", &self.kind)
|
||||||
|
.field("model", &self.model)
|
||||||
|
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||||
|
.field("base_url", &self.base_url)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_env_non_empty(key: &str) -> Option<String> {
|
||||||
|
std::env::var(key)
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
impl ProviderClient {
|
impl ProviderClient {
|
||||||
|
/// Construct a client from an explicit provider configuration.
|
||||||
|
///
|
||||||
|
/// The persisted provider kind is authoritative for provider selection,
|
||||||
|
/// while environment credentials and base URLs retain precedence over
|
||||||
|
/// persisted values.
|
||||||
|
pub fn from_config(config: &ProviderConfig) -> Result<Self, ApiError> {
|
||||||
|
match config.kind {
|
||||||
|
ProviderConfigKind::Anthropic => {
|
||||||
|
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")
|
||||||
|
.or_else(|| config.api_key.clone())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError::missing_credentials(
|
||||||
|
"Anthropic",
|
||||||
|
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut client = AnthropicClient::new(api_key);
|
||||||
|
let base_url = read_env_non_empty("ANTHROPIC_BASE_URL")
|
||||||
|
.or_else(|| config.base_url.clone())
|
||||||
|
.unwrap_or_else(anthropic::read_base_url);
|
||||||
|
client = client.with_base_url(base_url);
|
||||||
|
Ok(Self::Anthropic(client))
|
||||||
|
}
|
||||||
|
ProviderConfigKind::Xai => {
|
||||||
|
let compat = OpenAiCompatConfig::xai();
|
||||||
|
let api_key =
|
||||||
|
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
|
||||||
|
|
||||||
|
let mut client = match api_key {
|
||||||
|
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
|
||||||
|
None => OpenAiCompatClient::from_env(compat)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let base_url = read_env_non_empty(compat.base_url_env)
|
||||||
|
.or_else(|| config.base_url.clone())
|
||||||
|
.unwrap_or_else(|| compat.default_base_url.to_string());
|
||||||
|
client = client.with_base_url(base_url);
|
||||||
|
|
||||||
|
Ok(Self::Xai(client))
|
||||||
|
}
|
||||||
|
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
|
||||||
|
let compat = match config.kind {
|
||||||
|
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
|
||||||
|
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
|
||||||
|
_ => unreachable!("non-OpenAI provider reached compatibility path"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if config.kind == ProviderConfigKind::OpenAi
|
||||||
|
&& read_env_non_empty("OLLAMA_HOST").is_some()
|
||||||
|
&& config.api_key.is_none()
|
||||||
|
&& config.base_url.is_none()
|
||||||
|
{
|
||||||
|
return Ok(Self::OpenAi(
|
||||||
|
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||||
|
.expect("from_ollama_env always returns Some"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_key =
|
||||||
|
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
|
||||||
|
let persisted_base_url = config.base_url.clone();
|
||||||
|
let mut client = match api_key {
|
||||||
|
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
|
||||||
|
None if config.kind == ProviderConfigKind::OpenAi
|
||||||
|
&& persisted_base_url
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(openai_compat::is_local_openai_compatible_base_url) =>
|
||||||
|
{
|
||||||
|
OpenAiCompatClient::new("local-dev-token", compat)
|
||||||
|
}
|
||||||
|
None => OpenAiCompatClient::from_env(compat)?,
|
||||||
|
};
|
||||||
|
let base_url = read_env_non_empty(compat.base_url_env)
|
||||||
|
.or(persisted_base_url)
|
||||||
|
.unwrap_or_else(|| compat.default_base_url.to_string());
|
||||||
|
client = client.with_base_url(base_url);
|
||||||
|
|
||||||
|
Ok(Self::OpenAi(client))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pub fn from_model(model: &str) -> Result<Self, ApiError> {
|
pub fn from_model(model: &str) -> Result<Self, ApiError> {
|
||||||
Self::from_model_with_anthropic_auth(model, None)
|
Self::from_model_with_anthropic_auth(model, None)
|
||||||
}
|
}
|
||||||
@@ -34,7 +157,7 @@ impl ProviderClient {
|
|||||||
ProviderKind::OpenAi => {
|
ProviderKind::OpenAi => {
|
||||||
// OLLAMA_HOST takes priority: local Ollama needs no API key
|
// OLLAMA_HOST takes priority: local Ollama needs no API key
|
||||||
// and ignores DashScope/OpenAI env-based dispatch.
|
// and ignores DashScope/OpenAI env-based dispatch.
|
||||||
if std::env::var_os("OLLAMA_HOST").is_some() {
|
if read_env_non_empty("OLLAMA_HOST").is_some() {
|
||||||
Ok(Self::OpenAi(
|
Ok(Self::OpenAi(
|
||||||
openai_compat::OpenAiCompatClient::from_ollama_env()
|
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||||
.expect("from_ollama_env always returns Some"),
|
.expect("from_ollama_env always returns Some"),
|
||||||
@@ -155,7 +278,7 @@ pub fn read_xai_base_url() -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
use super::ProviderClient;
|
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
|
||||||
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
|
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
|
||||||
|
|
||||||
/// Serializes every test in this module that mutates process-wide
|
/// Serializes every test in this module that mutates process-wide
|
||||||
@@ -213,6 +336,73 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_provider_kind_overrides_model_provider_detection() {
|
||||||
|
let _lock = env_lock();
|
||||||
|
let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
|
||||||
|
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("test-xai-key"));
|
||||||
|
|
||||||
|
let config = ProviderConfig {
|
||||||
|
kind: ProviderConfigKind::Xai,
|
||||||
|
model: "claude-sonnet-4-6".to_string(),
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match ProviderClient::from_config(&config).expect("explicit xAI config should succeed") {
|
||||||
|
ProviderClient::Xai(client) => {
|
||||||
|
assert!(client.base_url().contains("api.x.ai"));
|
||||||
|
}
|
||||||
|
other => panic!("Expected explicit xAI provider, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_dashscope_kind_overrides_qwen_model_routing() {
|
||||||
|
let _lock = env_lock();
|
||||||
|
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
|
||||||
|
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
|
||||||
|
|
||||||
|
let config = ProviderConfig {
|
||||||
|
kind: ProviderConfigKind::DashScope,
|
||||||
|
model: "gpt-5".to_string(),
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match ProviderClient::from_config(&config)
|
||||||
|
.expect("explicit DashScope config should succeed")
|
||||||
|
{
|
||||||
|
ProviderClient::OpenAi(client) => {
|
||||||
|
assert!(client.base_url().contains("dashscope.aliyuncs.com"));
|
||||||
|
}
|
||||||
|
other => panic!("Expected explicit DashScope provider, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_openai_kind_does_not_become_dashscope_from_model() {
|
||||||
|
let _lock = env_lock();
|
||||||
|
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
|
||||||
|
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
|
||||||
|
let _openai_base_url = EnvVarGuard::set("OPENAI_BASE_URL", None);
|
||||||
|
|
||||||
|
let config = ProviderConfig {
|
||||||
|
kind: ProviderConfigKind::OpenAi,
|
||||||
|
model: "qwen-plus".to_string(),
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match ProviderClient::from_config(&config).expect("explicit OpenAI config should succeed") {
|
||||||
|
ProviderClient::OpenAi(client) => {
|
||||||
|
assert!(client.base_url().contains("api.openai.com"));
|
||||||
|
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
|
||||||
|
}
|
||||||
|
other => panic!("Expected explicit OpenAI provider, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dashscope_model_uses_dashscope_config_not_openai() {
|
fn dashscope_model_uses_dashscope_config_not_openai() {
|
||||||
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
|
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod client;
|
mod client;
|
||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
mod http_client;
|
mod http_client;
|
||||||
mod prompt_cache;
|
mod prompt_cache;
|
||||||
@@ -8,7 +9,8 @@ mod types;
|
|||||||
|
|
||||||
pub use client::{
|
pub use client::{
|
||||||
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
|
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
|
||||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
|
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
|
||||||
|
ProviderConfigKind,
|
||||||
};
|
};
|
||||||
pub use error::ApiError;
|
pub use error::ApiError;
|
||||||
pub use http_client::{
|
pub use http_client::{
|
||||||
|
|||||||
@@ -5297,6 +5297,60 @@ fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_session_status(session: &Session) -> String {
|
||||||
|
let message_count = session.messages.len();
|
||||||
|
let mut total_input = 0u32;
|
||||||
|
let mut total_output = 0u32;
|
||||||
|
for msg in &session.messages {
|
||||||
|
if let Some(usage) = msg.usage {
|
||||||
|
total_input = total_input.saturating_add(usage.input_tokens);
|
||||||
|
total_output = total_output.saturating_add(usage.output_tokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"Session {}: {} messages, {} input tokens, {} output tokens",
|
||||||
|
&session.session_id[0..8],
|
||||||
|
message_count,
|
||||||
|
total_input,
|
||||||
|
total_output
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_token_cost(session: &Session) -> String {
|
||||||
|
let mut total_input = 0u32;
|
||||||
|
let mut total_output = 0u32;
|
||||||
|
let mut cache_creation = 0u32;
|
||||||
|
let mut cache_read = 0u32;
|
||||||
|
for msg in &session.messages {
|
||||||
|
if let Some(usage) = msg.usage {
|
||||||
|
total_input = total_input.saturating_add(usage.input_tokens);
|
||||||
|
total_output = total_output.saturating_add(usage.output_tokens);
|
||||||
|
cache_creation = cache_creation.saturating_add(usage.cache_creation_input_tokens);
|
||||||
|
cache_read = cache_read.saturating_add(usage.cache_read_input_tokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"Cumulative token usage for this session:
|
||||||
|
Input tokens: {}
|
||||||
|
Output tokens: {}
|
||||||
|
Cache creation tokens: {}
|
||||||
|
Cache read tokens: {}
|
||||||
|
Total input: {}",
|
||||||
|
total_input,
|
||||||
|
total_output,
|
||||||
|
cache_creation,
|
||||||
|
cache_read,
|
||||||
|
total_input
|
||||||
|
.saturating_add(cache_creation)
|
||||||
|
.saturating_add(cache_read)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_version_info() -> String {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
format!("claw-code version {}", version)
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn handle_slash_command(
|
pub fn handle_slash_command(
|
||||||
input: &str,
|
input: &str,
|
||||||
@@ -5334,8 +5388,19 @@ pub fn handle_slash_command(
|
|||||||
message: render_slash_command_help(),
|
message: render_slash_command_help(),
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Status
|
SlashCommand::Status => Some(SlashCommandResult {
|
||||||
| SlashCommand::Bughunter { .. }
|
message: render_session_status(session),
|
||||||
|
session: session.clone(),
|
||||||
|
}),
|
||||||
|
SlashCommand::Cost => Some(SlashCommandResult {
|
||||||
|
message: render_token_cost(session),
|
||||||
|
session: session.clone(),
|
||||||
|
}),
|
||||||
|
SlashCommand::Version => Some(SlashCommandResult {
|
||||||
|
message: render_version_info(),
|
||||||
|
session: session.clone(),
|
||||||
|
}),
|
||||||
|
SlashCommand::Bughunter { .. }
|
||||||
| SlashCommand::Commit
|
| SlashCommand::Commit
|
||||||
| SlashCommand::Pr { .. }
|
| SlashCommand::Pr { .. }
|
||||||
| SlashCommand::Issue { .. }
|
| SlashCommand::Issue { .. }
|
||||||
@@ -5346,7 +5411,6 @@ pub fn handle_slash_command(
|
|||||||
| SlashCommand::Model { .. }
|
| SlashCommand::Model { .. }
|
||||||
| SlashCommand::Permissions { .. }
|
| SlashCommand::Permissions { .. }
|
||||||
| SlashCommand::Clear { .. }
|
| SlashCommand::Clear { .. }
|
||||||
| SlashCommand::Cost
|
|
||||||
| SlashCommand::Resume { .. }
|
| SlashCommand::Resume { .. }
|
||||||
| SlashCommand::Config { .. }
|
| SlashCommand::Config { .. }
|
||||||
| SlashCommand::Workspace { .. }
|
| SlashCommand::Workspace { .. }
|
||||||
@@ -5354,7 +5418,6 @@ pub fn handle_slash_command(
|
|||||||
| SlashCommand::Memory
|
| SlashCommand::Memory
|
||||||
| SlashCommand::Init
|
| SlashCommand::Init
|
||||||
| SlashCommand::Diff
|
| SlashCommand::Diff
|
||||||
| SlashCommand::Version
|
|
||||||
| SlashCommand::Export { .. }
|
| SlashCommand::Export { .. }
|
||||||
| SlashCommand::Session { .. }
|
| SlashCommand::Session { .. }
|
||||||
| SlashCommand::Plugins { .. }
|
| SlashCommand::Plugins { .. }
|
||||||
@@ -6193,7 +6256,6 @@ mod tests {
|
|||||||
fn ignores_unknown_or_runtime_bound_slash_commands() {
|
fn ignores_unknown_or_runtime_bound_slash_commands() {
|
||||||
let session = Session::new();
|
let session = Session::new();
|
||||||
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
|
|
||||||
assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(
|
assert!(
|
||||||
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
|
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
|
||||||
@@ -6225,7 +6287,6 @@ mod tests {
|
|||||||
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
|
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
|
||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
|
|
||||||
assert!(handle_slash_command(
|
assert!(handle_slash_command(
|
||||||
"/resume session.json",
|
"/resume session.json",
|
||||||
&session,
|
&session,
|
||||||
@@ -6244,7 +6305,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
|
||||||
assert!(
|
assert!(
|
||||||
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
|
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
|
||||||
.is_none()
|
.is_none()
|
||||||
@@ -7206,4 +7266,79 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(config_home);
|
let _ = fs::remove_dir_all(config_home);
|
||||||
let _ = fs::remove_dir_all(bundled_root);
|
let _ = fs::remove_dir_all(bundled_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_command_shows_session_info() {
|
||||||
|
use runtime::TokenUsage;
|
||||||
|
|
||||||
|
let mut session = Session::new();
|
||||||
|
session.messages.push(ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
blocks: vec![ContentBlock::Text {
|
||||||
|
text: "hello".to_string(),
|
||||||
|
}],
|
||||||
|
usage: Some(TokenUsage {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 20,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
session.messages.push(ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
blocks: vec![ContentBlock::Text {
|
||||||
|
text: "response".to_string(),
|
||||||
|
}],
|
||||||
|
usage: Some(TokenUsage {
|
||||||
|
input_tokens: 5,
|
||||||
|
output_tokens: 15,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = handle_slash_command("/status", &session, CompactionConfig::default())
|
||||||
|
.expect("/status should be handled");
|
||||||
|
assert!(result.message.contains("2 messages"));
|
||||||
|
assert!(result.message.contains("15 input tokens"));
|
||||||
|
assert!(result.message.contains("35 output tokens"));
|
||||||
|
assert_eq!(result.session, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cost_command_shows_token_usage() {
|
||||||
|
use runtime::TokenUsage;
|
||||||
|
|
||||||
|
let mut session = Session::new();
|
||||||
|
session.messages.push(ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
blocks: vec![ContentBlock::Text {
|
||||||
|
text: "hello".to_string(),
|
||||||
|
}],
|
||||||
|
usage: Some(TokenUsage {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 200,
|
||||||
|
cache_creation_input_tokens: 50,
|
||||||
|
cache_read_input_tokens: 25,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = handle_slash_command("/cost", &session, CompactionConfig::default())
|
||||||
|
.expect("/cost should be handled");
|
||||||
|
assert!(result.message.contains("100"));
|
||||||
|
assert!(result.message.contains("200"));
|
||||||
|
assert!(result.message.contains("50"));
|
||||||
|
assert!(result.message.contains("25"));
|
||||||
|
assert!(result.message.contains("Cumulative token usage"));
|
||||||
|
assert_eq!(result.session, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_command_shows_version() {
|
||||||
|
let session = Session::new();
|
||||||
|
let result = handle_slash_command("/version", &session, CompactionConfig::default())
|
||||||
|
.expect("/version should be handled");
|
||||||
|
assert!(result.message.contains("claw-code version"));
|
||||||
|
assert_eq!(result.session, session);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -205,6 +205,11 @@ pub struct RuntimeProviderConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeProviderConfig {
|
impl RuntimeProviderConfig {
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_configured(&self) -> bool {
|
||||||
|
self.kind.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn kind(&self) -> Option<&str> {
|
pub fn kind(&self) -> Option<&str> {
|
||||||
self.kind.as_deref()
|
self.kind.as_deref()
|
||||||
@@ -2658,6 +2663,42 @@ mod tests {
|
|||||||
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
|
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_persisted_provider_configuration_from_settings() {
|
||||||
|
let root = temp_dir();
|
||||||
|
let cwd = root.join("project");
|
||||||
|
let home = root.join("home").join(".claw");
|
||||||
|
fs::create_dir_all(&home).expect("home config dir");
|
||||||
|
fs::create_dir_all(&cwd).expect("project dir");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
home.join("settings.json"),
|
||||||
|
r#"{
|
||||||
|
"provider": {
|
||||||
|
"kind": "dashscope",
|
||||||
|
"apiKey": "persisted-test-key",
|
||||||
|
"baseUrl": "https://dashscope.example/v1",
|
||||||
|
"model": "qwen-plus"
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write settings");
|
||||||
|
|
||||||
|
let loaded = ConfigLoader::new(&cwd, &home)
|
||||||
|
.load()
|
||||||
|
.expect("provider configuration should load");
|
||||||
|
|
||||||
|
let provider = loaded.provider();
|
||||||
|
|
||||||
|
assert_eq!(provider.kind(), Some("dashscope"));
|
||||||
|
assert_eq!(provider.api_key(), Some("persisted-test-key"));
|
||||||
|
assert_eq!(provider.base_url(), Some("https://dashscope.example/v1"));
|
||||||
|
assert_eq!(provider.model(), Some("qwen-plus"));
|
||||||
|
assert!(provider.is_configured());
|
||||||
|
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_non_object_settings_files() {
|
fn rejects_non_object_settings_files() {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
|
|||||||
@@ -12708,6 +12708,7 @@ fn build_runtime_with_plugin_state(
|
|||||||
plugin_registry.initialize()?;
|
plugin_registry.initialize()?;
|
||||||
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
|
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
|
||||||
.map_err(std::io::Error::other)?;
|
.map_err(std::io::Error::other)?;
|
||||||
|
let provider_config = provider_config_from_runtime(feature_config.provider())?;
|
||||||
let mut runtime = ConversationRuntime::new_with_features(
|
let mut runtime = ConversationRuntime::new_with_features(
|
||||||
session,
|
session,
|
||||||
AnthropicRuntimeClient::new(
|
AnthropicRuntimeClient::new(
|
||||||
@@ -12719,6 +12720,7 @@ fn build_runtime_with_plugin_state(
|
|||||||
allowed_tools.clone(),
|
allowed_tools.clone(),
|
||||||
tool_registry.clone(),
|
tool_registry.clone(),
|
||||||
progress_reporter,
|
progress_reporter,
|
||||||
|
provider_config,
|
||||||
)?,
|
)?,
|
||||||
CliToolExecutor::new(
|
CliToolExecutor::new(
|
||||||
allowed_tools.clone(),
|
allowed_tools.clone(),
|
||||||
@@ -12891,10 +12893,85 @@ impl<W: Write> Write for TailTrackingWriter<'_, W> {
|
|||||||
|
|
||||||
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
||||||
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
|
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
|
||||||
// OpenAI, or DashScope at construction time based on
|
// OpenAI, or DashScope at construction time. Explicit persisted provider
|
||||||
// `detect_provider_kind(&model)`. The struct name is kept to avoid
|
// configuration takes precedence; model-based provider detection remains
|
||||||
// churning `BuiltRuntime` and every Deref/DerefMut site that references
|
// the fallback when no provider configuration is present. The struct name
|
||||||
// it. See ROADMAP #29 for the provider-dispatch routing fix.
|
// is kept to avoid churning `BuiltRuntime` and every Deref/DerefMut site
|
||||||
|
// that references it.
|
||||||
|
|
||||||
|
fn provider_config_from_runtime(
|
||||||
|
provider: &runtime::RuntimeProviderConfig,
|
||||||
|
) -> Result<Option<api::ProviderConfig>, Box<dyn std::error::Error>> {
|
||||||
|
let Some(kind) = provider.kind() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let kind = match kind.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"anthropic" => api::ProviderConfigKind::Anthropic,
|
||||||
|
"xai" => api::ProviderConfigKind::Xai,
|
||||||
|
"openai" => api::ProviderConfigKind::OpenAi,
|
||||||
|
"dashscope" => api::ProviderConfigKind::DashScope,
|
||||||
|
other => {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid provider configuration: unsupported provider kind `{other}`"
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(api::ProviderConfig {
|
||||||
|
kind,
|
||||||
|
model: provider.model().unwrap_or_default().to_string(),
|
||||||
|
api_key: provider.api_key().map(str::to_string),
|
||||||
|
base_url: provider.base_url().map(str::to_string),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn explicit_provider_kind_for_model(
|
||||||
|
model: &str,
|
||||||
|
persisted_model: &str,
|
||||||
|
) -> Option<api::ProviderConfigKind> {
|
||||||
|
let resolved_model = api::resolve_model_alias(model);
|
||||||
|
let persisted_resolved = api::resolve_model_alias(persisted_model);
|
||||||
|
|
||||||
|
if resolved_model.eq_ignore_ascii_case(&persisted_resolved) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let namespace = model
|
||||||
|
.trim()
|
||||||
|
.split_once('/')
|
||||||
|
.map(|(namespace, _)| namespace.to_ascii_lowercase());
|
||||||
|
|
||||||
|
match namespace.as_deref() {
|
||||||
|
Some("anthropic") => Some(api::ProviderConfigKind::Anthropic),
|
||||||
|
Some("xai") => Some(api::ProviderConfigKind::Xai),
|
||||||
|
Some("openai") | Some("local") => Some(api::ProviderConfigKind::OpenAi),
|
||||||
|
Some("dashscope") | Some("qwen") | Some("kimi") => Some(api::ProviderConfigKind::DashScope),
|
||||||
|
_ => {
|
||||||
|
let lower = resolved_model.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("qwen/")
|
||||||
|
|| lower.starts_with("qwen-")
|
||||||
|
|| lower.starts_with("kimi/")
|
||||||
|
|| lower.starts_with("kimi-")
|
||||||
|
{
|
||||||
|
Some(api::ProviderConfigKind::DashScope)
|
||||||
|
} else if lower.starts_with("grok/") || lower.starts_with("grok-") {
|
||||||
|
Some(api::ProviderConfigKind::Xai)
|
||||||
|
} else if lower.starts_with("claude") || lower.starts_with("anthropic/") {
|
||||||
|
Some(api::ProviderConfigKind::Anthropic)
|
||||||
|
} else if lower.starts_with("openai/")
|
||||||
|
|| lower.starts_with("gpt-")
|
||||||
|
|| lower.starts_with("local/")
|
||||||
|
{
|
||||||
|
Some(api::ProviderConfigKind::OpenAi)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct AnthropicRuntimeClient {
|
struct AnthropicRuntimeClient {
|
||||||
runtime: tokio::runtime::Runtime,
|
runtime: tokio::runtime::Runtime,
|
||||||
client: ApiProviderClient,
|
client: ApiProviderClient,
|
||||||
@@ -12919,47 +12996,65 @@ impl AnthropicRuntimeClient {
|
|||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
tool_registry: GlobalToolRegistry,
|
tool_registry: GlobalToolRegistry,
|
||||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||||
|
provider_config: Option<api::ProviderConfig>,
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
// Dispatch to the correct provider at construction time.
|
// Explicit persisted provider configuration takes precedence.
|
||||||
// `ApiProviderClient` (exposed by the api crate as
|
// When absent, preserve the existing model-based provider detection.
|
||||||
// `ProviderClient`) is an enum over Anthropic / xAI / OpenAI
|
|
||||||
// variants, where xAI and OpenAI both use the OpenAI-compat
|
|
||||||
// wire format under the hood. We consult
|
|
||||||
// `detect_provider_kind(&resolved_model)` so model-name prefix
|
|
||||||
// routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over
|
|
||||||
// env-var presence.
|
|
||||||
//
|
|
||||||
// For Anthropic we build the client directly instead of going
|
|
||||||
// through `ApiProviderClient::from_model_with_anthropic_auth`
|
|
||||||
// so we can explicitly apply `api::read_base_url()` — that
|
|
||||||
// reads `ANTHROPIC_BASE_URL` and is required for the local
|
|
||||||
// mock-server test harness
|
|
||||||
// (`crates/rusty-claude-cli/tests/compact_output.rs`) to point
|
|
||||||
// claw at its fake Anthropic endpoint. We also attach a
|
|
||||||
// session-scoped prompt cache on the Anthropic path; the
|
|
||||||
// prompt cache is Anthropic-only so non-Anthropic variants
|
|
||||||
// skip it.
|
|
||||||
let resolved_model = api::resolve_model_alias(&model);
|
let resolved_model = api::resolve_model_alias(&model);
|
||||||
let client = match detect_provider_kind(&resolved_model) {
|
let provider_config = provider_config.map(|config| {
|
||||||
ProviderKind::Anthropic => {
|
if let Some(kind) = explicit_provider_kind_for_model(&model, config.model.as_str()) {
|
||||||
let auth = resolve_cli_auth_source()?;
|
api::ProviderConfig {
|
||||||
let inner = AnthropicClient::from_auth(auth)
|
kind,
|
||||||
.with_base_url(api::read_base_url())
|
model: resolved_model.clone(),
|
||||||
.with_prompt_cache(PromptCache::new(session_id));
|
..config
|
||||||
ApiProviderClient::Anthropic(inner)
|
}
|
||||||
|
} else {
|
||||||
|
config
|
||||||
}
|
}
|
||||||
ProviderKind::Xai | ProviderKind::OpenAi => {
|
});
|
||||||
// The api crate's `ProviderClient::from_model_with_anthropic_auth`
|
let client = if let Some(config) = provider_config {
|
||||||
// with `None` for the anthropic auth routes via
|
match config.kind {
|
||||||
// `detect_provider_kind` and builds an
|
api::ProviderConfigKind::Anthropic => {
|
||||||
// `OpenAiCompatClient::from_env` with the matching
|
let auth = match resolve_cli_auth_source() {
|
||||||
// `OpenAiCompatConfig` (openai / xai / dashscope).
|
Ok(auth) => auth,
|
||||||
// That reads the correct API-key env var and BASE_URL
|
Err(_) => {
|
||||||
// override internally, so this one call covers OpenAI,
|
config
|
||||||
// OpenRouter, xAI, DashScope, Ollama, and any other
|
.api_key
|
||||||
// OpenAI-compat endpoint users configure via
|
.clone()
|
||||||
// `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`.
|
.map(AuthSource::ApiKey)
|
||||||
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
|
.ok_or_else(|| {
|
||||||
|
api::ApiError::missing_credentials(
|
||||||
|
"Anthropic",
|
||||||
|
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut inner = AnthropicClient::from_auth(auth);
|
||||||
|
let base_url = std::env::var("ANTHROPIC_BASE_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.or_else(|| config.base_url.clone())
|
||||||
|
.unwrap_or_else(api::read_base_url);
|
||||||
|
inner = inner.with_base_url(base_url);
|
||||||
|
ApiProviderClient::Anthropic(
|
||||||
|
inner.with_prompt_cache(PromptCache::new(session_id)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => ApiProviderClient::from_config(&config)?,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match detect_provider_kind(&resolved_model) {
|
||||||
|
ProviderKind::Anthropic => {
|
||||||
|
let auth = resolve_cli_auth_source()?;
|
||||||
|
let inner = AnthropicClient::from_auth(auth)
|
||||||
|
.with_base_url(api::read_base_url())
|
||||||
|
.with_prompt_cache(PromptCache::new(session_id));
|
||||||
|
ApiProviderClient::Anthropic(inner)
|
||||||
|
}
|
||||||
|
ProviderKind::Xai | ProviderKind::OpenAi => {
|
||||||
|
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -19556,6 +19651,119 @@ UU conflicted.rs",
|
|||||||
let _ = fs::remove_dir_all(source_root);
|
let _ = fs::remove_dir_all(source_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_model_provider_hint_overrides_persisted_provider_kind() {
|
||||||
|
let config = api::ProviderConfig {
|
||||||
|
kind: api::ProviderConfigKind::DashScope,
|
||||||
|
model: "qwen-plus".to_string(),
|
||||||
|
api_key: Some("persisted-test-key".to_string()),
|
||||||
|
base_url: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
super::explicit_provider_kind_for_model("openai/gpt-5", &config.model),
|
||||||
|
Some(api::ProviderConfigKind::OpenAi)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::explicit_provider_kind_for_model("qwen-plus", &config.model),
|
||||||
|
None,
|
||||||
|
"matching persisted model must keep the persisted provider kind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_model_provider_hint_controls_runtime_provider_dispatch() {
|
||||||
|
let config = api::ProviderConfig {
|
||||||
|
kind: api::ProviderConfigKind::DashScope,
|
||||||
|
model: "qwen-plus".to_string(),
|
||||||
|
api_key: Some("persisted-test-key".to_string()),
|
||||||
|
base_url: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let runtime = super::AnthropicRuntimeClient::new(
|
||||||
|
"provider-resolution-explicit-model-test",
|
||||||
|
"openai/gpt-5".to_string(),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
TerminalTail::new(),
|
||||||
|
None,
|
||||||
|
GlobalToolRegistry::builtin(),
|
||||||
|
None,
|
||||||
|
Some(config),
|
||||||
|
)
|
||||||
|
.expect("runtime client should construct from explicit model provider hint");
|
||||||
|
|
||||||
|
match runtime.client {
|
||||||
|
api::ProviderClient::OpenAi(client) => {
|
||||||
|
assert!(client.base_url().contains("api.openai.com"));
|
||||||
|
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
|
||||||
|
}
|
||||||
|
other => panic!(
|
||||||
|
"explicit OpenAI model prefix must override persisted DashScope configuration, got {other:?}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_provider_configuration_controls_runtime_provider_dispatch() {
|
||||||
|
let config_home = temp_dir();
|
||||||
|
let workspace = temp_dir();
|
||||||
|
fs::create_dir_all(&config_home).expect("config home");
|
||||||
|
fs::create_dir_all(&workspace).expect("workspace");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
config_home.join("settings.json"),
|
||||||
|
r#"{
|
||||||
|
"provider": {
|
||||||
|
"kind": "dashscope",
|
||||||
|
"apiKey": "persisted-test-key",
|
||||||
|
"baseUrl": "https://dashscope.example/v1",
|
||||||
|
"model": "qwen-plus"
|
||||||
|
},
|
||||||
|
"model": "qwen-plus"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write provider settings");
|
||||||
|
|
||||||
|
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||||
|
let runtime_config = loader.load().expect("runtime config should load");
|
||||||
|
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
|
||||||
|
.expect("runtime plugin state should load");
|
||||||
|
|
||||||
|
let provider_config = super::provider_config_from_runtime(state.feature_config.provider())
|
||||||
|
.expect("provider configuration should adapt")
|
||||||
|
.expect("persisted provider configuration should be present");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
provider_config.kind,
|
||||||
|
api::ProviderConfigKind::DashScope,
|
||||||
|
"persisted provider kind must survive the runtime configuration boundary"
|
||||||
|
);
|
||||||
|
|
||||||
|
let runtime = super::AnthropicRuntimeClient::new(
|
||||||
|
"provider-resolution-test",
|
||||||
|
"qwen-plus".to_string(),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
TerminalTail::new(),
|
||||||
|
None,
|
||||||
|
GlobalToolRegistry::builtin(),
|
||||||
|
None,
|
||||||
|
Some(provider_config),
|
||||||
|
)
|
||||||
|
.expect("runtime client should construct from persisted provider configuration");
|
||||||
|
|
||||||
|
match runtime.client {
|
||||||
|
api::ProviderClient::OpenAi(_) => {}
|
||||||
|
other => panic!(
|
||||||
|
"persisted DashScope configuration must dispatch to the OpenAI-compatible client, got {other:?}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
|
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
|
||||||
|
|||||||
@@ -441,9 +441,6 @@ fn status_and_sandbox_emit_json_when_requested() {
|
|||||||
|
|
||||||
// #831: direct resume-safe slash commands should use the same local CliAction
|
// #831: direct resume-safe slash commands should use the same local CliAction
|
||||||
// JSON surfaces as their bare subcommands, not interactive_only guidance.
|
// JSON surfaces as their bare subcommands, not interactive_only guidance.
|
||||||
// Note: /sandbox status depends on kernel support for user namespaces.
|
|
||||||
// On systems with working user namespaces the status is "ok".
|
|
||||||
// On systems without user namespaces the status is "warn" (#731).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||||
let root = unique_temp_dir("direct-resume-safe-slash-831");
|
let root = unique_temp_dir("direct-resume-safe-slash-831");
|
||||||
@@ -454,11 +451,11 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
|||||||
.output()
|
.output()
|
||||||
.expect("git init should launch");
|
.expect("git init should launch");
|
||||||
|
|
||||||
for (command, expected_kind, expected_statuses) in [
|
for (command, expected_kind, expected_status) in [
|
||||||
("/version", "version", &["ok"][..]),
|
("/version", "version", "ok"),
|
||||||
("/sandbox", "sandbox", &["ok", "warn"][..]),
|
("/sandbox", "sandbox", "warn"),
|
||||||
("/diff", "diff", &["ok"][..]),
|
("/diff", "diff", "ok"),
|
||||||
("/status", "status", &["ok"][..]),
|
("/status", "status", "ok"),
|
||||||
] {
|
] {
|
||||||
let output = run_claw(&root, &["--output-format", "json", command], &[]);
|
let output = run_claw(&root, &["--output-format", "json", command], &[]);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -473,9 +470,9 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
|||||||
.unwrap_or_else(|_| panic!("{command} must emit JSON (#831), got: {stdout:?}"));
|
.unwrap_or_else(|_| panic!("{command} must emit JSON (#831), got: {stdout:?}"));
|
||||||
|
|
||||||
assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}");
|
assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}");
|
||||||
assert!(
|
assert_eq!(
|
||||||
expected_statuses.contains(&parsed["status"].as_str().unwrap_or("")),
|
parsed["status"], expected_status,
|
||||||
"{command} status must be one of {expected_statuses:?}: {parsed}"
|
"{command} status: {parsed}"
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
parsed["error_kind"], "interactive_only",
|
parsed["error_kind"], "interactive_only",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "sdk"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-tungstenite = "0.23"
|
||||||
|
futures-util = "0.3"
|
||||||
|
serde_json = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
thiserror = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4"
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
//! SDK error types and result type.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// SDK wire protocol error codes.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ErrorCode {
|
||||||
|
/// Invalid JSON or malformed frame
|
||||||
|
InvalidInput,
|
||||||
|
/// Unknown broker operation
|
||||||
|
UnknownOperation,
|
||||||
|
/// Payload too large (>4 MiB)
|
||||||
|
PayloadTooLarge,
|
||||||
|
/// Authentication token mismatch
|
||||||
|
Unauthorized,
|
||||||
|
/// Broker or session unavailable
|
||||||
|
Unavailable,
|
||||||
|
/// WebSocket connection error
|
||||||
|
ConnectionError,
|
||||||
|
/// Request timeout
|
||||||
|
Timeout,
|
||||||
|
/// Frame parsing or validation error
|
||||||
|
FrameError,
|
||||||
|
/// Request/response correlation failure
|
||||||
|
CorrelationError,
|
||||||
|
/// Other unclassified error
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ErrorCode {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidInput => write!(f, "invalid_input"),
|
||||||
|
Self::UnknownOperation => write!(f, "unknown_operation"),
|
||||||
|
Self::PayloadTooLarge => write!(f, "payload_too_large"),
|
||||||
|
Self::Unauthorized => write!(f, "unauthorized"),
|
||||||
|
Self::Unavailable => write!(f, "unavailable"),
|
||||||
|
Self::ConnectionError => write!(f, "connection_error"),
|
||||||
|
Self::Timeout => write!(f, "timeout"),
|
||||||
|
Self::FrameError => write!(f, "frame_error"),
|
||||||
|
Self::CorrelationError => write!(f, "correlation_error"),
|
||||||
|
Self::Internal => write!(f, "internal_error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SDK wire protocol error.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SdkError {
|
||||||
|
pub code: ErrorCode,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SdkError {
|
||||||
|
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invalid_input(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::InvalidInput, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unknown_operation(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::UnknownOperation, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn payload_too_large(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::PayloadTooLarge, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::Unauthorized, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unavailable(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::Unavailable, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connection_error(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::ConnectionError, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timeout(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::Timeout, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frame_error(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::FrameError, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn correlation_error(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::CorrelationError, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn internal(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(ErrorCode::Internal, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SdkError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}: {}", self.code, self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SdkError {}
|
||||||
|
|
||||||
|
/// Result type for SDK operations.
|
||||||
|
pub type SdkResult<T> = Result<T, SdkError>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_display_formats_code_and_message() {
|
||||||
|
let err = SdkError::invalid_input("test message");
|
||||||
|
assert_eq!(err.to_string(), "invalid_input: test message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_code_display_works() {
|
||||||
|
assert_eq!(ErrorCode::InvalidInput.to_string(), "invalid_input");
|
||||||
|
assert_eq!(ErrorCode::PayloadTooLarge.to_string(), "payload_too_large");
|
||||||
|
assert_eq!(ErrorCode::Unauthorized.to_string(), "unauthorized");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
//! Frame types and serialization for the SDK wire protocol.
|
||||||
|
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
use crate::error::{SdkError, SdkResult};
|
||||||
|
|
||||||
|
/// Protocol version implemented.
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 3;
|
||||||
|
|
||||||
|
/// Maximum UTF-8 byte length for a single broker JSON frame.
|
||||||
|
pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Frame type identifier.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum FrameType {
|
||||||
|
/// Hello frame sent by broker on connection
|
||||||
|
BrokerHello,
|
||||||
|
/// Request frame from client to broker
|
||||||
|
BrokerRequest,
|
||||||
|
/// Response frame from broker to client
|
||||||
|
BrokerResponse,
|
||||||
|
/// Query request
|
||||||
|
QueryRequest,
|
||||||
|
/// Query response
|
||||||
|
QueryResponse,
|
||||||
|
/// Control request
|
||||||
|
ControlRequest,
|
||||||
|
/// Control response
|
||||||
|
ControlResponse,
|
||||||
|
/// Event replay request
|
||||||
|
EventReplay,
|
||||||
|
/// Event replay result
|
||||||
|
EventReplayResult,
|
||||||
|
/// Session activation
|
||||||
|
SessionActivate,
|
||||||
|
/// Session activation result
|
||||||
|
SessionActivateResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameType {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::BrokerHello => "broker_hello",
|
||||||
|
Self::BrokerRequest => "broker_request",
|
||||||
|
Self::BrokerResponse => "broker_response",
|
||||||
|
Self::QueryRequest => "query_request",
|
||||||
|
Self::QueryResponse => "query_response",
|
||||||
|
Self::ControlRequest => "control_request",
|
||||||
|
Self::ControlResponse => "control_response",
|
||||||
|
Self::EventReplay => "event_replay",
|
||||||
|
Self::EventReplayResult => "event_replay_result",
|
||||||
|
Self::SessionActivate => "session_activate",
|
||||||
|
Self::SessionActivateResult => "session_activate_result",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(s: &str) -> SdkResult<Self> {
|
||||||
|
match s {
|
||||||
|
"broker_hello" => Ok(Self::BrokerHello),
|
||||||
|
"broker_request" => Ok(Self::BrokerRequest),
|
||||||
|
"broker_response" => Ok(Self::BrokerResponse),
|
||||||
|
"query_request" => Ok(Self::QueryRequest),
|
||||||
|
"query_response" => Ok(Self::QueryResponse),
|
||||||
|
"control_request" => Ok(Self::ControlRequest),
|
||||||
|
"control_response" => Ok(Self::ControlResponse),
|
||||||
|
"event_replay" => Ok(Self::EventReplay),
|
||||||
|
"event_replay_result" => Ok(Self::EventReplayResult),
|
||||||
|
"session_activate" => Ok(Self::SessionActivate),
|
||||||
|
"session_activate_result" => Ok(Self::SessionActivateResult),
|
||||||
|
_ => Err(SdkError::frame_error(format!("unknown frame type: {}", s))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A frame in the SDK wire protocol.
|
||||||
|
///
|
||||||
|
/// Frames are JSON objects with a `type` field and optional `id` field for correlation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Frame {
|
||||||
|
/// Underlying JSON value
|
||||||
|
pub data: Map<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Frame {
|
||||||
|
/// Create a new frame from a JSON object.
|
||||||
|
pub fn new(data: Map<String, Value>) -> Self {
|
||||||
|
Self { data }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an empty frame.
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self { data: Map::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the frame type.
|
||||||
|
pub fn frame_type(&self) -> SdkResult<FrameType> {
|
||||||
|
let frame_type_str = self
|
||||||
|
.data
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| SdkError::frame_error("missing 'type' field"))?;
|
||||||
|
FrameType::parse(frame_type_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the optional request/response ID for correlation.
|
||||||
|
pub fn id(&self) -> Option<String> {
|
||||||
|
self.data
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(String::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the `ok` boolean field from a response frame.
|
||||||
|
pub fn ok(&self) -> SdkResult<bool> {
|
||||||
|
self.data
|
||||||
|
.get("ok")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.ok_or_else(|| SdkError::frame_error("missing or invalid 'ok' field"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the error object from a response frame.
|
||||||
|
pub fn error(&self) -> Option<SdkResult<(String, String)>> {
|
||||||
|
let error_obj = self.data.get("error")?.as_object()?;
|
||||||
|
let code = error_obj.get("code")?.as_str()?.to_string();
|
||||||
|
let message = error_obj.get("message")?.as_str()?.to_string();
|
||||||
|
Some(Ok((code, message)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the result field from a response frame.
|
||||||
|
pub fn result(&self) -> Option<Value> {
|
||||||
|
self.data.get("result").cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to JSON string.
|
||||||
|
pub fn to_json(&self) -> SdkResult<String> {
|
||||||
|
serde_json::to_string(&self.data)
|
||||||
|
.map_err(|e| SdkError::frame_error(format!("JSON serialization error: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from JSON string.
|
||||||
|
pub fn from_json(json_str: &str) -> SdkResult<Self> {
|
||||||
|
let data: Map<String, Value> = serde_json::from_str(json_str)
|
||||||
|
.map_err(|e| SdkError::frame_error(format!("JSON parsing error: {}", e)))?;
|
||||||
|
Ok(Self::new(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a broker hello frame.
|
||||||
|
pub fn broker_hello() -> Self {
|
||||||
|
let mut data = Map::new();
|
||||||
|
data.insert(
|
||||||
|
"type".to_string(),
|
||||||
|
Value::String("broker_hello".to_string()),
|
||||||
|
);
|
||||||
|
data.insert(
|
||||||
|
"protocolVersion".to_string(),
|
||||||
|
Value::Number(PROTOCOL_VERSION.into()),
|
||||||
|
);
|
||||||
|
Self::new(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a broker request frame.
|
||||||
|
pub fn broker_request(id: String, operation: String, input: Value) -> Self {
|
||||||
|
let mut data = Map::new();
|
||||||
|
data.insert(
|
||||||
|
"type".to_string(),
|
||||||
|
Value::String("broker_request".to_string()),
|
||||||
|
);
|
||||||
|
data.insert("id".to_string(), Value::String(id));
|
||||||
|
data.insert("operation".to_string(), Value::String(operation));
|
||||||
|
data.insert("input".to_string(), input);
|
||||||
|
Self::new(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a broker response frame.
|
||||||
|
pub fn broker_response_ok(id: String, result: Value) -> Self {
|
||||||
|
let mut data = Map::new();
|
||||||
|
data.insert(
|
||||||
|
"type".to_string(),
|
||||||
|
Value::String("broker_response".to_string()),
|
||||||
|
);
|
||||||
|
data.insert("id".to_string(), Value::String(id));
|
||||||
|
data.insert("ok".to_string(), Value::Bool(true));
|
||||||
|
data.insert("result".to_string(), result);
|
||||||
|
Self::new(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a broker error response frame.
|
||||||
|
pub fn broker_response_error(id: String, code: String, message: String) -> Self {
|
||||||
|
let mut error_data = Map::new();
|
||||||
|
error_data.insert("code".to_string(), Value::String(code));
|
||||||
|
error_data.insert("message".to_string(), Value::String(message));
|
||||||
|
|
||||||
|
let mut data = Map::new();
|
||||||
|
data.insert(
|
||||||
|
"type".to_string(),
|
||||||
|
Value::String("broker_response".to_string()),
|
||||||
|
);
|
||||||
|
data.insert("id".to_string(), Value::String(id));
|
||||||
|
data.insert("ok".to_string(), Value::Bool(false));
|
||||||
|
data.insert("error".to_string(), Value::Object(error_data));
|
||||||
|
Self::new(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the payload size in bytes.
|
||||||
|
pub fn byte_size(&self) -> usize {
|
||||||
|
self.to_json().map(|s| s.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_type_round_trip() {
|
||||||
|
assert_eq!(FrameType::BrokerHello.as_str(), "broker_hello");
|
||||||
|
assert_eq!(
|
||||||
|
FrameType::parse("broker_hello").unwrap(),
|
||||||
|
FrameType::BrokerHello
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_parsing_extracts_type() {
|
||||||
|
let frame = Frame::broker_hello();
|
||||||
|
assert_eq!(frame.frame_type().unwrap(), FrameType::BrokerHello);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_with_id_can_be_recovered() {
|
||||||
|
let frame = Frame::broker_request(
|
||||||
|
"test-id".to_string(),
|
||||||
|
"test.operation".to_string(),
|
||||||
|
Value::Object(Map::new()),
|
||||||
|
);
|
||||||
|
assert_eq!(frame.id(), Some("test-id".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_json_round_trip() {
|
||||||
|
let frame = Frame::broker_hello();
|
||||||
|
let json_str = frame.to_json().unwrap();
|
||||||
|
let parsed = Frame::from_json(&json_str).unwrap();
|
||||||
|
assert_eq!(parsed.frame_type().unwrap(), FrameType::BrokerHello);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn broker_response_error_contains_code_and_message() {
|
||||||
|
let frame = Frame::broker_response_error(
|
||||||
|
"id1".to_string(),
|
||||||
|
"invalid_input".to_string(),
|
||||||
|
"test error".to_string(),
|
||||||
|
);
|
||||||
|
let (code, message) = frame.error().unwrap().unwrap();
|
||||||
|
assert_eq!(code, "invalid_input");
|
||||||
|
assert_eq!(message, "test error");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! GJC SDK wire transport implementation.
|
||||||
|
//!
|
||||||
|
//! This crate provides a Rust implementation of the GJC SDK wire protocol,
|
||||||
|
//! compatible with the gajae-code SDK broker framing, request/response correlation,
|
||||||
|
//! and event stream semantics.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![allow(
|
||||||
|
clippy::missing_errors_doc,
|
||||||
|
clippy::missing_panics_doc,
|
||||||
|
clippy::module_name_repetitions
|
||||||
|
)]
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
pub mod frame;
|
||||||
|
pub mod transport;
|
||||||
|
|
||||||
|
pub use error::{SdkError, SdkResult};
|
||||||
|
pub use frame::{Frame, FrameType};
|
||||||
|
pub use transport::{SdkTransport, SdkTransportConfig};
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
//! WebSocket transport implementation for the SDK wire protocol.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use futures_util::sink::SinkExt;
|
||||||
|
use futures_util::stream::StreamExt;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::sync::{mpsc, oneshot, Mutex, RwLock};
|
||||||
|
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{SdkError, SdkResult};
|
||||||
|
use crate::frame::{Frame, FrameType, MAX_FRAME_BYTES};
|
||||||
|
|
||||||
|
/// Configuration for SDK transport.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SdkTransportConfig {
|
||||||
|
/// Server URL (e.g., "ws://127.0.0.1:8080")
|
||||||
|
pub url: String,
|
||||||
|
/// Authentication token
|
||||||
|
pub token: String,
|
||||||
|
/// Maximum payload size in bytes (default: 4 MiB)
|
||||||
|
pub max_payload_bytes: usize,
|
||||||
|
/// Request timeout in seconds (default: 30)
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SdkTransportConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
url: "ws://127.0.0.1:8080".to_string(),
|
||||||
|
token: String::new(),
|
||||||
|
max_payload_bytes: MAX_FRAME_BYTES,
|
||||||
|
timeout_secs: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pending request tracking for correlation.
|
||||||
|
struct PendingRequest {
|
||||||
|
response_tx: oneshot::Sender<SdkResult<Frame>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SDK WebSocket transport.
|
||||||
|
pub struct SdkTransport {
|
||||||
|
/// Channel for sending messages to the writer task
|
||||||
|
tx: mpsc::UnboundedSender<Message>,
|
||||||
|
/// Pending requests by ID
|
||||||
|
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
|
||||||
|
/// Configuration
|
||||||
|
config: SdkTransportConfig,
|
||||||
|
/// Background task handle
|
||||||
|
task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SdkTransport {
|
||||||
|
/// Create a new SDK transport and connect to the broker.
|
||||||
|
pub async fn connect(config: SdkTransportConfig) -> SdkResult<Self> {
|
||||||
|
// Add token to query params
|
||||||
|
let url_with_token = if config.url.contains('?') {
|
||||||
|
format!("{}&token={}", config.url, config.token)
|
||||||
|
} else {
|
||||||
|
format!("{}?token={}", config.url, config.token)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create WebSocket connection
|
||||||
|
let (ws, _) = connect_async(&url_with_token).await.map_err(|e| {
|
||||||
|
SdkError::connection_error(format!("WebSocket connection failed: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (ws_tx, ws_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let pending = Arc::new(RwLock::new(HashMap::new()));
|
||||||
|
let pending_clone = pending.clone();
|
||||||
|
|
||||||
|
// Spawn background task for handling the WebSocket
|
||||||
|
let task_handle = tokio::spawn(async move {
|
||||||
|
Self::background_task(ws, ws_rx, pending_clone).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let transport = Self {
|
||||||
|
tx: ws_tx,
|
||||||
|
pending,
|
||||||
|
config,
|
||||||
|
task_handle: Arc::new(Mutex::new(Some(task_handle))),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a request and wait for response.
|
||||||
|
pub async fn request(&self, operation: String, input: Value) -> SdkResult<Frame> {
|
||||||
|
// Generate correlation ID
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// Create request frame
|
||||||
|
let request = Frame::broker_request(id.clone(), operation, input);
|
||||||
|
|
||||||
|
// Check payload size
|
||||||
|
if request.byte_size() > self.config.max_payload_bytes {
|
||||||
|
return Err(SdkError::payload_too_large(format!(
|
||||||
|
"frame size {} exceeds maximum {}",
|
||||||
|
request.byte_size(),
|
||||||
|
self.config.max_payload_bytes
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create response channel
|
||||||
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
|
self.pending
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(id.clone(), PendingRequest { response_tx });
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
let json = request.to_json()?;
|
||||||
|
self.tx
|
||||||
|
.send(Message::Text(json))
|
||||||
|
.map_err(|e| SdkError::connection_error(format!("send failed: {}", e)))?;
|
||||||
|
|
||||||
|
// Wait for response with timeout
|
||||||
|
let timeout_duration = std::time::Duration::from_secs(self.config.timeout_secs);
|
||||||
|
match tokio::time::timeout(timeout_duration, response_rx).await {
|
||||||
|
Ok(Ok(frame)) => frame,
|
||||||
|
Ok(Err(_)) => Err(SdkError::correlation_error("response channel closed")),
|
||||||
|
Err(_) => Err(SdkError::timeout("response not received within timeout")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background task for handling WebSocket communication.
|
||||||
|
#[allow(unused_mut)]
|
||||||
|
async fn background_task(
|
||||||
|
ws: tokio_tungstenite::WebSocketStream<
|
||||||
|
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||||
|
>,
|
||||||
|
mut ws_rx: mpsc::UnboundedReceiver<Message>,
|
||||||
|
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
|
||||||
|
) {
|
||||||
|
let (mut ws_sink, mut ws_stream) = ws.split();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Handle incoming WebSocket messages
|
||||||
|
msg = ws_stream.next() => {
|
||||||
|
match msg {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
// Parse frame
|
||||||
|
match Frame::from_json(&text) {
|
||||||
|
Ok(frame) => {
|
||||||
|
// Handle frame type
|
||||||
|
match frame.frame_type() {
|
||||||
|
Ok(FrameType::BrokerHello) => {
|
||||||
|
// Hello frame received, continue
|
||||||
|
}
|
||||||
|
Ok(FrameType::BrokerResponse) => {
|
||||||
|
// Response frame, correlate with request
|
||||||
|
if let Some(id) = frame.id() {
|
||||||
|
if let Some(PendingRequest { response_tx }) =
|
||||||
|
pending.write().await.remove(&id)
|
||||||
|
{
|
||||||
|
let _ = response_tx.send(Ok(frame));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Other frame types
|
||||||
|
tracing::debug!("Unhandled frame type: {:?}", frame.frame_type());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to parse frame: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Close(_))) => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(Ok(_)) => {
|
||||||
|
// Other message types (binary, etc.), ignore
|
||||||
|
}
|
||||||
|
Some(Err(e)) => {
|
||||||
|
tracing::warn!("WebSocket error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle outgoing messages
|
||||||
|
Some(msg) = ws_rx.recv() => {
|
||||||
|
if let Err(e) = ws_sink.send(msg).await {
|
||||||
|
tracing::warn!("Failed to send message: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail all pending requests
|
||||||
|
let mut pending = pending.write().await;
|
||||||
|
for (_, PendingRequest { response_tx }) in pending.drain() {
|
||||||
|
let _ = response_tx.send(Err(SdkError::connection_error("connection closed")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the transport.
|
||||||
|
pub async fn close(self) -> SdkResult<()> {
|
||||||
|
// Send close message
|
||||||
|
let _ = self.tx.send(Message::Close(None));
|
||||||
|
|
||||||
|
// Wait for background task to finish
|
||||||
|
if let Some(handle) = self.task_handle.lock().await.take() {
|
||||||
|
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_default_values() {
|
||||||
|
let config = SdkTransportConfig::default();
|
||||||
|
assert_eq!(config.url, "ws://127.0.0.1:8080");
|
||||||
|
assert_eq!(config.max_payload_bytes, MAX_FRAME_BYTES);
|
||||||
|
assert_eq!(config.timeout_secs, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transport_can_be_created() {
|
||||||
|
// This test doesn't actually connect, just checks that the struct can be created
|
||||||
|
let config = SdkTransportConfig::default();
|
||||||
|
assert_eq!(config.url, "ws://127.0.0.1:8080");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_frame_has_correlation_id() {
|
||||||
|
let frame = Frame::broker_request(
|
||||||
|
"test-id".to_string(),
|
||||||
|
"test.operation".to_string(),
|
||||||
|
Value::Object(serde_json::Map::new()),
|
||||||
|
);
|
||||||
|
assert_eq!(frame.id(), Some("test-id".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user