mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-14 00:00:19 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66698e8aef |
@@ -57,7 +57,7 @@ jobs:
|
||||
mkdir -p dist
|
||||
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
|
||||
chmod +x "dist/${{ matrix.artifact_name }}"
|
||||
(cd dist && (sha256sum "${{ matrix.artifact_name }}" 2>/dev/null || shasum -a 256 "${{ matrix.artifact_name }}") > "${{ matrix.artifact_name }}.sha256")
|
||||
(cd dist && sha256sum "${{ matrix.artifact_name }}" > "${{ matrix.artifact_name }}.sha256")
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
|
||||
@@ -25,8 +25,6 @@ on:
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
- flake.nix
|
||||
- .envrc
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
@@ -49,8 +47,6 @@ on:
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
- flake.nix
|
||||
- .envrc
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -15,7 +15,3 @@ status-help.txt
|
||||
.port_sessions/
|
||||
# Local agent session state
|
||||
.gjc/
|
||||
# Nix build outputs and direnv
|
||||
.envrc.local
|
||||
result
|
||||
result-*
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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,25 +130,6 @@ 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.
|
||||
|
||||
## 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
|
||||
|
||||
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/`.
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
# Nix flake development environment
|
||||
|
||||
This repository includes a **`flake.nix`** for reproducible development environments on NixOS and systems with Nix installed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Nix** (version 2.4 or later with flakes support enabled)
|
||||
- **direnv** (optional, for automatic environment loading via `.envrc`)
|
||||
|
||||
To verify Nix is installed and flakes are enabled:
|
||||
|
||||
```bash
|
||||
nix --version
|
||||
nix flake --help
|
||||
```
|
||||
|
||||
If flakes are not enabled, add this to your `~/.config/nix/nix.conf`:
|
||||
|
||||
```
|
||||
experimental-features = nix-command flakes
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### Enter the development environment
|
||||
|
||||
**Option 1: Using `nix flake`** (without direnv)
|
||||
|
||||
```bash
|
||||
nix flake update # Update inputs (optional, pins nixpkgs)
|
||||
nix develop # Enter default dev shell
|
||||
nix develop .#full # Enter full dev shell with extra tools
|
||||
exit # Leave the shell
|
||||
```
|
||||
|
||||
**Option 2: Using direnv** (automatic on directory entry)
|
||||
|
||||
```bash
|
||||
direnv allow # Trust the .envrc file (one-time setup)
|
||||
cd . # Direnv reloads the flake automatically
|
||||
```
|
||||
|
||||
When you leave the repository directory, the environment is automatically unloaded.
|
||||
|
||||
## Development shells
|
||||
|
||||
The flake provides two development shells:
|
||||
|
||||
### `default` (minimal)
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
Includes:
|
||||
- Rust (stable, latest)
|
||||
- Rust source code (`rust-src`)
|
||||
- `cargo-edit` (dependency management helpers)
|
||||
- `cargo-expand` (macro expansion tool)
|
||||
- `cargo-watch` (file watcher for incremental builds)
|
||||
- Build tools: `pkg-config`, `openssl`, `git`, `ca-certificates`
|
||||
|
||||
### `full` (with extra tooling)
|
||||
|
||||
```bash
|
||||
nix develop .#full
|
||||
```
|
||||
|
||||
Includes everything from `default` plus:
|
||||
- `clippy` (linter)
|
||||
- `rustfmt` (code formatter)
|
||||
- `mold` (fast linker)
|
||||
- `lldb` (debugger)
|
||||
|
||||
## Building the claw binary
|
||||
|
||||
### With the dev environment
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
cd rust
|
||||
cargo build -p rusty-claude-cli
|
||||
```
|
||||
|
||||
### Directly with `nix build`
|
||||
|
||||
```bash
|
||||
nix build .#claw # Build the claw binary
|
||||
nix build # Same as .#claw (default package)
|
||||
```
|
||||
|
||||
The compiled binary is symlinked to `./result/bin/claw`.
|
||||
|
||||
## Running common tasks
|
||||
|
||||
Once in the dev shell:
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
cd rust
|
||||
cargo fmt
|
||||
|
||||
# Run clippy linter
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# Run all tests
|
||||
cargo test --workspace
|
||||
|
||||
# Run tests for a specific crate
|
||||
cargo test -p rusty-claude-cli
|
||||
|
||||
# Build with optimizations
|
||||
cargo build --release -p rusty-claude-cli
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
The flake automatically sets:
|
||||
|
||||
- `RUSTUP_TOOLCHAIN=stable` — uses stable Rust from nixpkgs
|
||||
- Standard Rust build flags are inherited from the environment
|
||||
|
||||
For development, the minimal dev shell includes a helpful message on entry:
|
||||
|
||||
```
|
||||
Claw Code development environment
|
||||
Run 'cargo build -p rusty-claude-cli' to build the claw binary
|
||||
Run 'cargo test --workspace' to run tests
|
||||
Run 'scripts/fmt.sh' to format code
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `command not found: nix`
|
||||
|
||||
Nix is not installed. Install it from [nixos.org/download.html](https://nixos.org/download.html).
|
||||
|
||||
### `error: experimental Nix feature 'flakes' is disabled`
|
||||
|
||||
Enable flakes in your Nix configuration (`~/.config/nix/nix.conf` or `/etc/nix/nix.conf`):
|
||||
|
||||
```
|
||||
experimental-features = nix-command flakes
|
||||
```
|
||||
|
||||
### `error: ... does not provide attribute 'claw'`
|
||||
|
||||
The flake structure may have changed. Try updating:
|
||||
|
||||
```bash
|
||||
nix flake update
|
||||
nix flake check
|
||||
```
|
||||
|
||||
### Rebuilds are slow after updating dependencies
|
||||
|
||||
Cargo will recompile dependencies if `Cargo.lock` changes. This is expected:
|
||||
|
||||
```bash
|
||||
nix build --rebuild
|
||||
```
|
||||
|
||||
### `cache lock file needs to be updated`
|
||||
|
||||
The Cargo.lock in `rust/` needs to be committed. Run:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo generate-lockfile
|
||||
git add Cargo.lock
|
||||
git commit -m "update: Cargo.lock"
|
||||
```
|
||||
|
||||
## Integration with other tools
|
||||
|
||||
### IDE integration (VS Code, IntelliJ, etc.)
|
||||
|
||||
Many IDEs can detect and use Nix environments. Direnv integration is recommended:
|
||||
|
||||
1. Install the direnv extension
|
||||
2. Enable `direnv` shell integration in your IDE
|
||||
3. Run `direnv allow` in the repository
|
||||
|
||||
Your IDE will automatically use the correct Rust toolchain and dependencies from the flake.
|
||||
|
||||
### CI/CD
|
||||
|
||||
For GitHub Actions, consider using [cachix/install-nix-action](https://github.com/cachix/install-nix-action) to set up Nix on the runner:
|
||||
|
||||
```yaml
|
||||
- uses: cachix/install-nix-action@v18
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
extra_nix_config: "experimental-features = nix-command flakes"
|
||||
|
||||
- run: nix develop -c cargo test --workspace
|
||||
```
|
||||
|
||||
## More information
|
||||
|
||||
- [NixOS flakes documentation](https://nixos.wiki/wiki/Flakes)
|
||||
- [Direnv documentation](https://direnv.net/)
|
||||
- [Rust on Nix](https://github.com/oxalica/rust-overlay)
|
||||
@@ -1,115 +0,0 @@
|
||||
{
|
||||
description = "claw-code: Claude Code CLI agent harness in Rust";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs {
|
||||
inherit system overlays;
|
||||
};
|
||||
|
||||
# Build inputs needed for Rust and the project
|
||||
buildInputs = with pkgs; [
|
||||
pkg-config
|
||||
openssl
|
||||
ca-certificates
|
||||
git
|
||||
];
|
||||
|
||||
# Runtime inputs for the dev environment
|
||||
nativeBuildInputs = with pkgs; [
|
||||
(rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
targets = [ ];
|
||||
})
|
||||
cargo-edit
|
||||
cargo-expand
|
||||
cargo-watch
|
||||
];
|
||||
|
||||
# Derivation to build the claw binary
|
||||
clawBinary = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "claw";
|
||||
version = (builtins.fromTOML (builtins.readFile ./rust/Cargo.toml)).workspace.package.version;
|
||||
|
||||
src = ./rust;
|
||||
|
||||
# Hash of all the dependencies in Cargo.lock
|
||||
# This needs to be updated when dependencies change
|
||||
# Run: nix flake update
|
||||
cargoLock = {
|
||||
lockFile = ./rust/Cargo.lock;
|
||||
};
|
||||
|
||||
cargoBuildFlags = [ "-p" "rusty-claude-cli" ];
|
||||
|
||||
buildInputs = buildInputs ++ (with pkgs; [
|
||||
openssl
|
||||
]);
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
];
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Claude Code CLI: agent harness with Rust implementation";
|
||||
homepage = "https://github.com/ultraworkers/claw-code";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
# Provide the claw binary as a package
|
||||
packages.claw = clawBinary;
|
||||
packages.default = clawBinary;
|
||||
|
||||
# Development shell for contributors
|
||||
devShells.default = pkgs.mkShell {
|
||||
name = "claw-dev";
|
||||
inherit buildInputs nativeBuildInputs;
|
||||
|
||||
shellHook = ''
|
||||
echo "Claw Code development environment"
|
||||
echo "Run 'cargo build -p rusty-claude-cli' to build the claw binary"
|
||||
echo "Run 'cargo test --workspace' to run tests"
|
||||
echo "Run 'scripts/fmt.sh' to format code"
|
||||
'';
|
||||
|
||||
# Make sure we have the right Rust toolchain
|
||||
RUSTUP_TOOLCHAIN = "stable";
|
||||
};
|
||||
|
||||
# Alternative shell with additional development tools
|
||||
devShells.full = pkgs.mkShell {
|
||||
name = "claw-dev-full";
|
||||
inherit buildInputs nativeBuildInputs;
|
||||
|
||||
nativeBuildInputs = nativeBuildInputs ++ (with pkgs; [
|
||||
clippy
|
||||
rustfmt
|
||||
mold
|
||||
lldb
|
||||
]);
|
||||
|
||||
shellHook = ''
|
||||
echo "Claw Code full development environment (with extra tools)"
|
||||
echo "Available tools: clippy, rustfmt, mold (linker), lldb (debugger)"
|
||||
'';
|
||||
|
||||
RUSTUP_TOOLCHAIN = "stable";
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
+43
-135
@@ -349,12 +349,6 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -389,17 +383,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
@@ -733,12 +716,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1022,15 +999,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1904,15 +1881,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.19"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -1949,9 +1925,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "radix_trie"
|
||||
@@ -1970,19 +1946,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1995,6 +1970,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -2006,17 +1991,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2331,21 +2310,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -2450,17 +2414,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -2632,7 +2585,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -2753,7 +2706,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.6.3",
|
||||
@@ -2793,29 +2745,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -3027,24 +2956,6 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -3093,12 +3004,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -3111,18 +3016,6 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -3160,6 +3053,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.116"
|
||||
@@ -3468,6 +3370,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
|
||||
@@ -13,130 +13,7 @@ pub enum ProviderClient {
|
||||
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 {
|
||||
/// 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> {
|
||||
Self::from_model_with_anthropic_auth(model, None)
|
||||
}
|
||||
@@ -157,7 +34,7 @@ impl ProviderClient {
|
||||
ProviderKind::OpenAi => {
|
||||
// OLLAMA_HOST takes priority: local Ollama needs no API key
|
||||
// and ignores DashScope/OpenAI env-based dispatch.
|
||||
if read_env_non_empty("OLLAMA_HOST").is_some() {
|
||||
if std::env::var_os("OLLAMA_HOST").is_some() {
|
||||
Ok(Self::OpenAi(
|
||||
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||
.expect("from_ollama_env always returns Some"),
|
||||
@@ -278,7 +155,7 @@ pub fn read_xai_base_url() -> String {
|
||||
mod tests {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
|
||||
use super::ProviderClient;
|
||||
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
|
||||
|
||||
/// Serializes every test in this module that mutates process-wide
|
||||
@@ -336,73 +213,6 @@ 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]
|
||||
fn dashscope_model_uses_dashscope_config_not_openai() {
|
||||
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
mod client;
|
||||
|
||||
mod error;
|
||||
mod http_client;
|
||||
mod prompt_cache;
|
||||
@@ -9,8 +8,7 @@ mod types;
|
||||
|
||||
pub use client::{
|
||||
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
|
||||
ProviderConfigKind,
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
|
||||
};
|
||||
pub use error::ApiError;
|
||||
pub use http_client::{
|
||||
|
||||
@@ -1131,9 +1131,6 @@ pub enum SlashCommand {
|
||||
SecurityReview,
|
||||
Keybindings,
|
||||
PrivacySettings,
|
||||
Workspace {
|
||||
path: Option<String>,
|
||||
},
|
||||
Plan {
|
||||
mode: Option<String>,
|
||||
},
|
||||
@@ -1275,7 +1272,6 @@ impl SlashCommand {
|
||||
Self::SecurityReview => "/security-review",
|
||||
Self::Keybindings => "/keybindings",
|
||||
Self::PrivacySettings => "/privacy-settings",
|
||||
Self::Workspace { .. } => "/workspace",
|
||||
Self::Plan { .. } => "/plan",
|
||||
Self::Review { .. } => "/review",
|
||||
Self::Tasks { .. } => "/tasks",
|
||||
@@ -1409,9 +1405,6 @@ pub fn validate_slash_command_input(
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Setup
|
||||
}
|
||||
"workspace" | "cwd" => SlashCommand::Workspace {
|
||||
path: optional_single_arg(command, &args, "[path]")?,
|
||||
},
|
||||
"login" | "logout" => {
|
||||
return Err(command_error(
|
||||
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
|
||||
@@ -5297,60 +5290,6 @@ 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]
|
||||
pub fn handle_slash_command(
|
||||
input: &str,
|
||||
@@ -5388,19 +5327,8 @@ pub fn handle_slash_command(
|
||||
message: render_slash_command_help(),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Status => Some(SlashCommandResult {
|
||||
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::Status
|
||||
| SlashCommand::Bughunter { .. }
|
||||
| SlashCommand::Commit
|
||||
| SlashCommand::Pr { .. }
|
||||
| SlashCommand::Issue { .. }
|
||||
@@ -5411,13 +5339,14 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Model { .. }
|
||||
| SlashCommand::Permissions { .. }
|
||||
| SlashCommand::Clear { .. }
|
||||
| SlashCommand::Cost
|
||||
| SlashCommand::Resume { .. }
|
||||
| SlashCommand::Config { .. }
|
||||
| SlashCommand::Workspace { .. }
|
||||
| SlashCommand::Mcp { .. }
|
||||
| SlashCommand::Memory
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Version
|
||||
| SlashCommand::Export { .. }
|
||||
| SlashCommand::Session { .. }
|
||||
| SlashCommand::Plugins { .. }
|
||||
@@ -6190,24 +6119,6 @@ mod tests {
|
||||
assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_workspace_slash_command_and_alias() {
|
||||
let workspace = validate_slash_command_input("/workspace")
|
||||
.expect("workspace should parse")
|
||||
.expect("workspace should be a slash command");
|
||||
assert_eq!(workspace, SlashCommand::Workspace { path: None });
|
||||
|
||||
let cwd = validate_slash_command_input("/cwd src")
|
||||
.expect("cwd alias should parse")
|
||||
.expect("cwd alias should be a slash command");
|
||||
assert_eq!(
|
||||
cwd,
|
||||
SlashCommand::Workspace {
|
||||
path: Some("src".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_sessions_via_slash_command() {
|
||||
let mut session = Session::new();
|
||||
@@ -6256,6 +6167,7 @@ mod tests {
|
||||
fn ignores_unknown_or_runtime_bound_slash_commands() {
|
||||
let session = Session::new();
|
||||
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("/bughunter", &session, CompactionConfig::default()).is_none()
|
||||
@@ -6287,6 +6199,7 @@ mod tests {
|
||||
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
);
|
||||
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command(
|
||||
"/resume session.json",
|
||||
&session,
|
||||
@@ -6305,6 +6218,7 @@ mod tests {
|
||||
);
|
||||
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("/version", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
@@ -7266,79 +7180,4 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
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,11 +205,6 @@ pub struct RuntimeProviderConfig {
|
||||
}
|
||||
|
||||
impl RuntimeProviderConfig {
|
||||
#[must_use]
|
||||
pub fn is_configured(&self) -> bool {
|
||||
self.kind.is_some()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> Option<&str> {
|
||||
self.kind.as_deref()
|
||||
@@ -2663,42 +2658,6 @@ mod tests {
|
||||
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]
|
||||
fn rejects_non_object_settings_files() {
|
||||
let root = temp_dir();
|
||||
|
||||
+236
-356
@@ -49,10 +49,10 @@ impl JsonValue {
|
||||
.join(",");
|
||||
format!("[{rendered}]")
|
||||
}
|
||||
Self::Object(map) => {
|
||||
let rendered = map
|
||||
Self::Object(entries) => {
|
||||
let rendered = entries
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}:{}", render_string(k), v.render()))
|
||||
.map(|(key, value)| format!("{}:{}", render_string(key), value.render()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!("{{{rendered}}}")
|
||||
@@ -60,26 +60,21 @@ impl JsonValue {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_bool(&self) -> Option<bool> {
|
||||
match self {
|
||||
Self::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
pub fn parse(source: &str) -> Result<Self, JsonError> {
|
||||
let mut parser = Parser::new(source);
|
||||
let value = parser.parse_value()?;
|
||||
parser.skip_whitespace();
|
||||
if parser.is_eof() {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(JsonError::new("unexpected trailing content"))
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_i64(&self) -> Option<i64> {
|
||||
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
|
||||
match self {
|
||||
Self::Number(n) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::String(s) => Some(s),
|
||||
Self::Object(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -87,354 +82,273 @@ impl JsonValue {
|
||||
#[must_use]
|
||||
pub fn as_array(&self) -> Option<&[JsonValue]> {
|
||||
match self {
|
||||
Self::Array(a) => Some(a),
|
||||
Self::Array(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Object(o) => Some(o),
|
||||
Self::String(value) => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(input: &str) -> Result<Self, JsonError> {
|
||||
let mut chars = input.trim().chars().peekable();
|
||||
let value = parse_value(&mut chars)?;
|
||||
if chars.peek().is_some() {
|
||||
return Err(JsonError::new("trailing content"));
|
||||
#[must_use]
|
||||
pub fn as_bool(&self) -> Option<bool> {
|
||||
match self {
|
||||
Self::Bool(value) => Some(*value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_i64(&self) -> Option<i64> {
|
||||
match self {
|
||||
Self::Number(value) => Some(*value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_string(value: &str) -> String {
|
||||
let mut rendered = String::with_capacity(value.len() + 2);
|
||||
rendered.push('"');
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'"' => rendered.push_str("\\\""),
|
||||
'\\' => rendered.push_str("\\\\"),
|
||||
'\n' => rendered.push_str("\\n"),
|
||||
'\r' => rendered.push_str("\\r"),
|
||||
'\t' => rendered.push_str("\\t"),
|
||||
'\u{08}' => rendered.push_str("\\b"),
|
||||
'\u{0C}' => rendered.push_str("\\f"),
|
||||
control if control.is_control() => push_unicode_escape(&mut rendered, control),
|
||||
plain => rendered.push(plain),
|
||||
}
|
||||
}
|
||||
rendered.push('"');
|
||||
rendered
|
||||
}
|
||||
|
||||
fn push_unicode_escape(rendered: &mut String, control: char) {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
|
||||
rendered.push_str("\\u");
|
||||
let value = u32::from(control);
|
||||
for shift in [12_u32, 8, 4, 0] {
|
||||
let nibble = ((value >> shift) & 0xF) as usize;
|
||||
rendered.push(char::from(HEX[nibble]));
|
||||
}
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
chars: Vec<char>,
|
||||
index: usize,
|
||||
_source: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn new(source: &'a str) -> Self {
|
||||
Self {
|
||||
chars: source.chars().collect(),
|
||||
index: 0,
|
||||
_source: source,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_value(&mut self) -> Result<JsonValue, JsonError> {
|
||||
self.skip_whitespace();
|
||||
match self.peek() {
|
||||
Some('n') => self.parse_literal("null", JsonValue::Null),
|
||||
Some('t') => self.parse_literal("true", JsonValue::Bool(true)),
|
||||
Some('f') => self.parse_literal("false", JsonValue::Bool(false)),
|
||||
Some('"') => self.parse_string().map(JsonValue::String),
|
||||
Some('[') => self.parse_array(),
|
||||
Some('{') => self.parse_object(),
|
||||
Some('-' | '0'..='9') => self.parse_number().map(JsonValue::Number),
|
||||
Some(other) => Err(JsonError::new(format!("unexpected character: {other}"))),
|
||||
None => Err(JsonError::new("unexpected end of input")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_literal(&mut self, expected: &str, value: JsonValue) -> Result<JsonValue, JsonError> {
|
||||
for expected_char in expected.chars() {
|
||||
if self.next() != Some(expected_char) {
|
||||
return Err(JsonError::new(format!(
|
||||
"invalid literal: expected {expected}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_value(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
|
||||
match chars.peek() {
|
||||
Some('n') => parse_null(chars),
|
||||
Some('t') | Some('f') => parse_bool(chars),
|
||||
Some('"') => parse_string(chars).map(JsonValue::String),
|
||||
Some('[') => parse_array(chars),
|
||||
Some('{') => parse_object(chars),
|
||||
Some('-') | Some('0'..='9') => parse_number(chars),
|
||||
Some(c) => Err(JsonError::new(format!("unexpected char: {c}"))),
|
||||
None => Err(JsonError::new("empty input")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_null(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
|
||||
if consume_literal(chars, "null") {
|
||||
Ok(JsonValue::Null)
|
||||
} else {
|
||||
Err(JsonError::new("invalid null"))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bool(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
|
||||
if consume_literal(chars, "true") {
|
||||
return Ok(JsonValue::Bool(true));
|
||||
}
|
||||
if consume_literal(chars, "false") {
|
||||
return Ok(JsonValue::Bool(false));
|
||||
}
|
||||
Err(JsonError::new("invalid bool"))
|
||||
}
|
||||
|
||||
fn parse_string(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String, JsonError> {
|
||||
if chars.next() != Some('"') {
|
||||
return Err(JsonError::new("expected opening quote"));
|
||||
fn parse_string(&mut self) -> Result<String, JsonError> {
|
||||
self.expect('"')?;
|
||||
let mut value = String::new();
|
||||
while let Some(ch) = self.next() {
|
||||
match ch {
|
||||
'"' => return Ok(value),
|
||||
'\\' => value.push(self.parse_escape()?),
|
||||
plain => value.push(plain),
|
||||
}
|
||||
}
|
||||
Err(JsonError::new("unterminated string"))
|
||||
}
|
||||
|
||||
let mut result = String::new();
|
||||
loop {
|
||||
match chars.next() {
|
||||
None => return Err(JsonError::new("unterminated string")),
|
||||
Some('"') => return Ok(result),
|
||||
Some('\\') => match chars.next() {
|
||||
Some('"') => result.push('"'),
|
||||
Some('\\') => result.push('\\'),
|
||||
Some('/') => result.push('/'),
|
||||
Some('b') => result.push('\u{08}'),
|
||||
Some('f') => result.push('\u{0C}'),
|
||||
Some('n') => result.push('\n'),
|
||||
Some('r') => result.push('\r'),
|
||||
Some('t') => result.push('\t'),
|
||||
Some('u') => {
|
||||
let mut hex = String::new();
|
||||
for _ in 0..4 {
|
||||
match chars.next() {
|
||||
Some(c @ '0'..='9') | Some(c @ 'a'..='f') | Some(c @ 'A'..='F') => {
|
||||
hex.push(c)
|
||||
}
|
||||
_ => return Err(JsonError::new("invalid unicode escape")),
|
||||
}
|
||||
}
|
||||
let code = u32::from_str_radix(&hex, 16)
|
||||
.map_err(|_| JsonError::new("invalid hex code"))?;
|
||||
if (0xD800..=0xDFFF).contains(&code) {
|
||||
return Err(JsonError::new("surrogate not allowed"));
|
||||
}
|
||||
result.push(
|
||||
char::from_u32(code).ok_or_else(|| JsonError::new("invalid codepoint"))?,
|
||||
);
|
||||
}
|
||||
_ => return Err(JsonError::new("invalid escape")),
|
||||
},
|
||||
Some(c) => result.push(c),
|
||||
fn parse_escape(&mut self) -> Result<char, JsonError> {
|
||||
match self.next() {
|
||||
Some('"') => Ok('"'),
|
||||
Some('\\') => Ok('\\'),
|
||||
Some('/') => Ok('/'),
|
||||
Some('b') => Ok('\u{08}'),
|
||||
Some('f') => Ok('\u{0C}'),
|
||||
Some('n') => Ok('\n'),
|
||||
Some('r') => Ok('\r'),
|
||||
Some('t') => Ok('\t'),
|
||||
Some('u') => self.parse_unicode_escape(),
|
||||
Some(other) => Err(JsonError::new(format!("invalid escape sequence: {other}"))),
|
||||
None => Err(JsonError::new("unexpected end of input in escape sequence")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_number(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
|
||||
let mut num_str = String::new();
|
||||
|
||||
if chars.peek() == Some(&'-') {
|
||||
num_str.push(chars.next().unwrap());
|
||||
fn parse_unicode_escape(&mut self) -> Result<char, JsonError> {
|
||||
let mut value = 0_u32;
|
||||
for _ in 0..4 {
|
||||
let Some(ch) = self.next() else {
|
||||
return Err(JsonError::new("unexpected end of input in unicode escape"));
|
||||
};
|
||||
value = (value << 4)
|
||||
| ch.to_digit(16)
|
||||
.ok_or_else(|| JsonError::new("invalid unicode escape"))?;
|
||||
}
|
||||
char::from_u32(value).ok_or_else(|| JsonError::new("invalid unicode scalar value"))
|
||||
}
|
||||
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_ascii_digit() {
|
||||
num_str.push(chars.next().unwrap());
|
||||
fn parse_array(&mut self) -> Result<JsonValue, JsonError> {
|
||||
self.expect('[')?;
|
||||
let mut values = Vec::new();
|
||||
loop {
|
||||
self.skip_whitespace();
|
||||
if self.try_consume(']') {
|
||||
break;
|
||||
}
|
||||
values.push(self.parse_value()?);
|
||||
self.skip_whitespace();
|
||||
if self.try_consume(']') {
|
||||
break;
|
||||
}
|
||||
self.expect(',')?;
|
||||
}
|
||||
Ok(JsonValue::Array(values))
|
||||
}
|
||||
|
||||
fn parse_object(&mut self) -> Result<JsonValue, JsonError> {
|
||||
self.expect('{')?;
|
||||
let mut entries = BTreeMap::new();
|
||||
loop {
|
||||
self.skip_whitespace();
|
||||
if self.try_consume('}') {
|
||||
break;
|
||||
}
|
||||
let key = self.parse_string()?;
|
||||
self.skip_whitespace();
|
||||
self.expect(':')?;
|
||||
let value = self.parse_value()?;
|
||||
entries.insert(key, value);
|
||||
self.skip_whitespace();
|
||||
if self.try_consume('}') {
|
||||
break;
|
||||
}
|
||||
self.expect(',')?;
|
||||
}
|
||||
Ok(JsonValue::Object(entries))
|
||||
}
|
||||
|
||||
fn parse_number(&mut self) -> Result<i64, JsonError> {
|
||||
let mut value = String::new();
|
||||
if self.try_consume('-') {
|
||||
value.push('-');
|
||||
}
|
||||
|
||||
while let Some(ch @ '0'..='9') = self.peek() {
|
||||
value.push(ch);
|
||||
self.index += 1;
|
||||
}
|
||||
|
||||
if value.is_empty() || value == "-" {
|
||||
return Err(JsonError::new("invalid number"));
|
||||
}
|
||||
|
||||
value
|
||||
.parse::<i64>()
|
||||
.map_err(|_| JsonError::new("number out of range"))
|
||||
}
|
||||
|
||||
fn expect(&mut self, expected: char) -> Result<(), JsonError> {
|
||||
match self.next() {
|
||||
Some(actual) if actual == expected => Ok(()),
|
||||
Some(actual) => Err(JsonError::new(format!(
|
||||
"expected '{expected}', found '{actual}'"
|
||||
))),
|
||||
None => Err(JsonError::new(format!(
|
||||
"expected '{expected}', found end of input"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_consume(&mut self, expected: char) -> bool {
|
||||
if self.peek() == Some(expected) {
|
||||
self.index += 1;
|
||||
true
|
||||
} else {
|
||||
break;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
if num_str.is_empty() || num_str == "-" {
|
||||
return Err(JsonError::new("invalid number"));
|
||||
}
|
||||
|
||||
num_str
|
||||
.parse::<i64>()
|
||||
.map(JsonValue::Number)
|
||||
.map_err(|_| JsonError::new("number out of range"))
|
||||
}
|
||||
|
||||
fn parse_array(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
|
||||
if chars.next() != Some('[') {
|
||||
return Err(JsonError::new("expected ["));
|
||||
}
|
||||
|
||||
let mut values = Vec::new();
|
||||
|
||||
skip_whitespace(chars);
|
||||
if chars.peek() == Some(&']') {
|
||||
chars.next();
|
||||
return Ok(JsonValue::Array(values));
|
||||
}
|
||||
|
||||
loop {
|
||||
skip_whitespace(chars);
|
||||
values.push(parse_value(chars)?);
|
||||
skip_whitespace(chars);
|
||||
|
||||
match chars.peek() {
|
||||
Some(',') => {
|
||||
chars.next();
|
||||
}
|
||||
Some(']') => {
|
||||
chars.next();
|
||||
return Ok(JsonValue::Array(values));
|
||||
}
|
||||
_ => return Err(JsonError::new("expected , or ]")),
|
||||
fn skip_whitespace(&mut self) {
|
||||
while matches!(self.peek(), Some(' ' | '\n' | '\r' | '\t')) {
|
||||
self.index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_object(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
|
||||
if chars.next() != Some('{') {
|
||||
return Err(JsonError::new("expected {"));
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.chars.get(self.index).copied()
|
||||
}
|
||||
|
||||
let mut map = BTreeMap::new();
|
||||
|
||||
skip_whitespace(chars);
|
||||
if chars.peek() == Some(&'}') {
|
||||
chars.next();
|
||||
return Ok(JsonValue::Object(map));
|
||||
fn next(&mut self) -> Option<char> {
|
||||
let ch = self.peek()?;
|
||||
self.index += 1;
|
||||
Some(ch)
|
||||
}
|
||||
|
||||
loop {
|
||||
skip_whitespace(chars);
|
||||
|
||||
if chars.peek() != Some(&'"') {
|
||||
return Err(JsonError::new("expected key string"));
|
||||
}
|
||||
let key = parse_string(chars)?;
|
||||
|
||||
skip_whitespace(chars);
|
||||
if chars.next() != Some(':') {
|
||||
return Err(JsonError::new("expected :"));
|
||||
}
|
||||
|
||||
skip_whitespace(chars);
|
||||
let value = parse_value(chars)?;
|
||||
map.insert(key, value);
|
||||
|
||||
skip_whitespace(chars);
|
||||
match chars.peek() {
|
||||
Some(',') => {
|
||||
chars.next();
|
||||
}
|
||||
Some('}') => {
|
||||
chars.next();
|
||||
return Ok(JsonValue::Object(map));
|
||||
}
|
||||
_ => return Err(JsonError::new("expected , or }")),
|
||||
}
|
||||
fn is_eof(&self) -> bool {
|
||||
self.index >= self.chars.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn consume_literal(chars: &mut std::iter::Peekable<std::str::Chars>, literal: &str) -> bool {
|
||||
let remaining: String = chars.clone().collect();
|
||||
if remaining.starts_with(literal) {
|
||||
for _ in literal.chars() {
|
||||
chars.next();
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_whitespace(chars: &mut std::iter::Peekable<std::str::Chars>) {
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_string(s: &str) -> String {
|
||||
let mut result = String::from('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => result.push_str("\\\""),
|
||||
'\\' => result.push_str("\\\\"),
|
||||
'\n' => result.push_str("\\n"),
|
||||
'\r' => result.push_str("\\r"),
|
||||
'\t' => result.push_str("\\t"),
|
||||
'\u{08}' => result.push_str("\\b"),
|
||||
'\u{0C}' => result.push_str("\\f"),
|
||||
c if c.is_control() => {
|
||||
let code = c as u32;
|
||||
result.push_str(&format!("\\u{code:04x}"));
|
||||
}
|
||||
c => result.push(c),
|
||||
}
|
||||
}
|
||||
result.push('"');
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::{render_string, JsonValue};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn renders_primitive_values() {
|
||||
assert_eq!(JsonValue::Null.render(), "null");
|
||||
assert_eq!(JsonValue::Bool(true).render(), "true");
|
||||
assert_eq!(JsonValue::Bool(false).render(), "false");
|
||||
assert_eq!(JsonValue::Number(42).render(), "42");
|
||||
assert_eq!(JsonValue::Number(-7).render(), "-7");
|
||||
assert_eq!(JsonValue::String("hi".to_string()).render(), "\"hi\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_arrays_and_objects_with_sorted_keys() {
|
||||
assert_eq!(JsonValue::Array(Vec::new()).render(), "[]");
|
||||
assert_eq!(
|
||||
JsonValue::Array(vec![JsonValue::Number(1), JsonValue::Number(2)]).render(),
|
||||
"[1,2]"
|
||||
);
|
||||
assert_eq!(JsonValue::Object(BTreeMap::new()).render(), "{}");
|
||||
|
||||
fn renders_and_parses_json_values() {
|
||||
let mut object = BTreeMap::new();
|
||||
object.insert("b".to_string(), JsonValue::Bool(false));
|
||||
object.insert("a".to_string(), JsonValue::Number(1));
|
||||
// A BTreeMap iterates keys in sorted order, so object rendering is
|
||||
// deterministic regardless of insertion order.
|
||||
assert_eq!(JsonValue::Object(object).render(), "{\"a\":1,\"b\":false}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_string_escapes_quotes_backslashes_and_other_controls() {
|
||||
// Backslash and double-quote are backslash-escaped.
|
||||
assert_eq!(render_string("\"\\"), "\"\\\"\\\\\"");
|
||||
// Carriage return has a dedicated short escape.
|
||||
assert_eq!(render_string("\r"), "\"\\r\"");
|
||||
// Other control characters fall back to the \uXXXX form (U+0001 here).
|
||||
assert_eq!(render_string("\u{1}"), "\"\\u0001\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_primitive_values() {
|
||||
assert_eq!(
|
||||
JsonValue::parse("null").expect("null parses"),
|
||||
JsonValue::Null
|
||||
);
|
||||
assert_eq!(
|
||||
JsonValue::parse("true").expect("true parses"),
|
||||
JsonValue::Bool(true)
|
||||
);
|
||||
assert_eq!(
|
||||
JsonValue::parse("false").expect("false parses"),
|
||||
JsonValue::Bool(false)
|
||||
);
|
||||
assert_eq!(
|
||||
JsonValue::parse("\"hello\"").expect("string parses"),
|
||||
JsonValue::String("hello".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_signed_integers_and_ignores_surrounding_whitespace() {
|
||||
assert_eq!(
|
||||
JsonValue::parse("-42").expect("negative parses").as_i64(),
|
||||
Some(-42)
|
||||
);
|
||||
assert_eq!(
|
||||
JsonValue::parse(" 123 ").expect("padded parses").as_i64(),
|
||||
Some(123)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_nested_arrays_and_objects() {
|
||||
let value =
|
||||
JsonValue::parse("{\"nums\": [1, 2], \"flag\": true}").expect("nested json parses");
|
||||
let object = value.as_object().expect("top level is an object");
|
||||
|
||||
assert_eq!(object.len(), 2);
|
||||
|
||||
let nums = object
|
||||
.get("nums")
|
||||
.and_then(JsonValue::as_array)
|
||||
.expect("nums is an array");
|
||||
assert_eq!(nums.len(), 2);
|
||||
assert_eq!(nums[0].as_i64(), Some(1));
|
||||
|
||||
assert_eq!(object.get("flag").and_then(JsonValue::as_bool), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_rendered_values_through_the_parser() {
|
||||
let mut object = BTreeMap::new();
|
||||
object.insert("name".to_string(), JsonValue::String("clawi".to_string()));
|
||||
object.insert("count".to_string(), JsonValue::Number(-3));
|
||||
object.insert("flag".to_string(), JsonValue::Bool(true));
|
||||
object.insert(
|
||||
"tags".to_string(),
|
||||
JsonValue::Array(vec![JsonValue::Bool(true), JsonValue::Null]),
|
||||
"items".to_string(),
|
||||
JsonValue::Array(vec![
|
||||
JsonValue::Number(4),
|
||||
JsonValue::String("ok".to_string()),
|
||||
]),
|
||||
);
|
||||
let original = JsonValue::Object(object);
|
||||
|
||||
let reparsed = JsonValue::parse(&original.render()).expect("rendered json should re-parse");
|
||||
let rendered = JsonValue::Object(object).render();
|
||||
let parsed = JsonValue::parse(&rendered).expect("json should parse");
|
||||
|
||||
assert_eq!(reparsed, original);
|
||||
assert_eq!(parsed.as_object().expect("object").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -519,7 +433,6 @@ mod tests {
|
||||
object.insert("a\nb".to_string(), JsonValue::String("c\td".to_string()));
|
||||
let original = JsonValue::Object(object);
|
||||
let reparsed = JsonValue::parse(&original.render()).expect("escaped key re-parses");
|
||||
|
||||
assert_eq!(reparsed, original);
|
||||
}
|
||||
|
||||
@@ -558,37 +471,4 @@ mod tests {
|
||||
"i64::MIN - 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_malformed_input() {
|
||||
assert!(JsonValue::parse("").is_err(), "empty input");
|
||||
assert!(JsonValue::parse("nul").is_err(), "truncated literal");
|
||||
assert!(JsonValue::parse("true false").is_err(), "trailing content");
|
||||
assert!(
|
||||
JsonValue::parse("\"unterminated").is_err(),
|
||||
"unterminated string"
|
||||
);
|
||||
assert!(JsonValue::parse("[1, 2").is_err(), "unterminated array");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_integers_outside_the_i64_range() {
|
||||
// Far beyond i64::MAX (~9.2e18): the parser must reject rather than
|
||||
// silently truncate the value.
|
||||
assert!(JsonValue::parse("9999999999999999999999999").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_return_none_for_mismatched_variants() {
|
||||
assert_eq!(JsonValue::Null.as_bool(), None);
|
||||
assert_eq!(JsonValue::Bool(true).as_i64(), None);
|
||||
assert!(JsonValue::Number(1).as_str().is_none());
|
||||
assert!(JsonValue::Number(1).as_array().is_none());
|
||||
assert!(JsonValue::String("x".to_string()).as_object().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_error_displays_its_message() {
|
||||
assert_eq!(JsonError::new("boom").to_string(), "boom");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6715,49 +6715,6 @@ fn run_resume_command(
|
||||
message: Some(render_memory_report()?),
|
||||
json: Some(render_memory_json()?),
|
||||
}),
|
||||
SlashCommand::Workspace { path } => {
|
||||
let cwd_before = env::current_dir()?;
|
||||
let workspace_root = session
|
||||
.workspace_root()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| cwd_before.clone());
|
||||
let workspace_root = canonicalize_or_clone(&workspace_root);
|
||||
let changed = if let Some(path) = path.as_deref() {
|
||||
let requested_path = Path::new(path);
|
||||
let resolved_path = if requested_path.is_absolute() {
|
||||
requested_path.to_path_buf()
|
||||
} else {
|
||||
cwd_before.join(requested_path)
|
||||
};
|
||||
let resolved_path = canonicalize_or_clone(&resolved_path);
|
||||
if !resolved_path.starts_with(&workspace_root) {
|
||||
return Err(format!(
|
||||
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
|
||||
resolved_path.display(),
|
||||
workspace_root.display(),
|
||||
resolved_path.display(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
env::set_current_dir(&resolved_path)?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let cwd_after = env::current_dir()?;
|
||||
let message =
|
||||
render_workspace_report(&cwd_after, &workspace_root, &session.session_id, changed);
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(message),
|
||||
json: Some(workspace_report_json(
|
||||
&cwd_after,
|
||||
&workspace_root,
|
||||
&session.session_id,
|
||||
changed,
|
||||
)),
|
||||
})
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
// #142: run the init once, then render both text + structured JSON
|
||||
// from the same InitReport so both surfaces stay in sync.
|
||||
@@ -8259,7 +8216,6 @@ impl LiveCli {
|
||||
Self::print_config(section.as_deref())?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Workspace { path } => self.handle_workspace_command(path.as_deref())?,
|
||||
SlashCommand::Mcp { action, target } => {
|
||||
let args = match (action.as_deref(), target.as_deref()) {
|
||||
(None, None) => None,
|
||||
@@ -8418,51 +8374,6 @@ impl LiveCli {
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_workspace_command(
|
||||
&mut self,
|
||||
target: Option<&str>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let current_dir = env::current_dir()?;
|
||||
let workspace_root = self
|
||||
.runtime
|
||||
.session()
|
||||
.workspace_root()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| current_dir.clone());
|
||||
let workspace_root = canonicalize_or_clone(&workspace_root);
|
||||
|
||||
if let Some(target) = target {
|
||||
let requested_path = Path::new(target);
|
||||
let resolved_path = if requested_path.is_absolute() {
|
||||
requested_path.to_path_buf()
|
||||
} else {
|
||||
current_dir.join(requested_path)
|
||||
};
|
||||
let resolved_path = canonicalize_or_clone(&resolved_path);
|
||||
if !resolved_path.starts_with(&workspace_root) {
|
||||
return Err(format!(
|
||||
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
|
||||
resolved_path.display(),
|
||||
workspace_root.display(),
|
||||
resolved_path.display(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
env::set_current_dir(&resolved_path)?;
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
render_workspace_report(
|
||||
&env::current_dir()?,
|
||||
&workspace_root,
|
||||
&self.session.id,
|
||||
target.is_some(),
|
||||
)
|
||||
);
|
||||
Ok(target.is_some())
|
||||
}
|
||||
|
||||
fn record_prompt_history(&mut self, prompt: &str) {
|
||||
let timestamp_ms = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -9303,41 +9214,6 @@ fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
|
||||
Ok(Session::new().with_workspace_root(env::current_dir()?))
|
||||
}
|
||||
|
||||
fn canonicalize_or_clone(path: &Path) -> PathBuf {
|
||||
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn render_workspace_report(
|
||||
cwd: &Path,
|
||||
workspace_root: &Path,
|
||||
session_id: &str,
|
||||
changed: bool,
|
||||
) -> String {
|
||||
let action = if changed { "change" } else { "show" };
|
||||
format!(
|
||||
"Workspace\n Action {action}\n Session {session_id}\n Workspace root {}\n Current directory {}",
|
||||
workspace_root.display(),
|
||||
cwd.display(),
|
||||
)
|
||||
}
|
||||
|
||||
fn workspace_report_json(
|
||||
cwd: &Path,
|
||||
workspace_root: &Path,
|
||||
session_id: &str,
|
||||
changed: bool,
|
||||
) -> Value {
|
||||
serde_json::json!({
|
||||
"kind": "workspace",
|
||||
"action": if changed { "change" } else { "show" },
|
||||
"status": "ok",
|
||||
"session_id": session_id,
|
||||
"workspace_root": workspace_root.display().to_string(),
|
||||
"current_directory": cwd.display().to_string(),
|
||||
"changed": changed,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_managed_session_handle(
|
||||
session_id: &str,
|
||||
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
||||
@@ -12708,7 +12584,6 @@ fn build_runtime_with_plugin_state(
|
||||
plugin_registry.initialize()?;
|
||||
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let provider_config = provider_config_from_runtime(feature_config.provider())?;
|
||||
let mut runtime = ConversationRuntime::new_with_features(
|
||||
session,
|
||||
AnthropicRuntimeClient::new(
|
||||
@@ -12720,7 +12595,6 @@ fn build_runtime_with_plugin_state(
|
||||
allowed_tools.clone(),
|
||||
tool_registry.clone(),
|
||||
progress_reporter,
|
||||
provider_config,
|
||||
)?,
|
||||
CliToolExecutor::new(
|
||||
allowed_tools.clone(),
|
||||
@@ -12893,85 +12767,10 @@ impl<W: Write> Write for TailTrackingWriter<'_, W> {
|
||||
|
||||
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
||||
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
|
||||
// OpenAI, or DashScope at construction time. Explicit persisted provider
|
||||
// configuration takes precedence; model-based provider detection remains
|
||||
// the fallback when no provider configuration is present. The struct name
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI, or DashScope at construction time based on
|
||||
// `detect_provider_kind(&model)`. The struct name is kept to avoid
|
||||
// churning `BuiltRuntime` and every Deref/DerefMut site that references
|
||||
// it. See ROADMAP #29 for the provider-dispatch routing fix.
|
||||
struct AnthropicRuntimeClient {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
client: ApiProviderClient,
|
||||
@@ -12996,65 +12795,47 @@ impl AnthropicRuntimeClient {
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
provider_config: Option<api::ProviderConfig>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// Explicit persisted provider configuration takes precedence.
|
||||
// When absent, preserve the existing model-based provider detection.
|
||||
// Dispatch to the correct provider at construction time.
|
||||
// `ApiProviderClient` (exposed by the api crate as
|
||||
// `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 provider_config = provider_config.map(|config| {
|
||||
if let Some(kind) = explicit_provider_kind_for_model(&model, config.model.as_str()) {
|
||||
api::ProviderConfig {
|
||||
kind,
|
||||
model: resolved_model.clone(),
|
||||
..config
|
||||
}
|
||||
} else {
|
||||
config
|
||||
let client = 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)
|
||||
}
|
||||
});
|
||||
let client = if let Some(config) = provider_config {
|
||||
match config.kind {
|
||||
api::ProviderConfigKind::Anthropic => {
|
||||
let auth = match resolve_cli_auth_source() {
|
||||
Ok(auth) => auth,
|
||||
Err(_) => {
|
||||
config
|
||||
.api_key
|
||||
.clone()
|
||||
.map(AuthSource::ApiKey)
|
||||
.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)?
|
||||
}
|
||||
ProviderKind::Xai | ProviderKind::OpenAi => {
|
||||
// The api crate's `ProviderClient::from_model_with_anthropic_auth`
|
||||
// with `None` for the anthropic auth routes via
|
||||
// `detect_provider_kind` and builds an
|
||||
// `OpenAiCompatClient::from_env` with the matching
|
||||
// `OpenAiCompatConfig` (openai / xai / dashscope).
|
||||
// That reads the correct API-key env var and BASE_URL
|
||||
// override internally, so this one call covers OpenAI,
|
||||
// OpenRouter, xAI, DashScope, Ollama, and any other
|
||||
// OpenAI-compat endpoint users configure via
|
||||
// `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`.
|
||||
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
@@ -19651,119 +19432,6 @@ UU conflicted.rs",
|
||||
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]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
[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"
|
||||
@@ -1,130 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//! 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};
|
||||
@@ -1,251 +0,0 @@
|
||||
//! 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