anveesa 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +68 -0
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +41 -0
- package/bin/anveesa +0 -0
- package/bin/anveesa.js +24 -38
- package/package.json +22 -9
- package/scripts/install.js +126 -34
- package/src/provider/openai_compatible.rs +47 -14
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
name: Release Binaries
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
include:
|
|
16
|
+
- os: ubuntu-latest
|
|
17
|
+
target: x86_64-unknown-linux-gnu
|
|
18
|
+
- os: ubuntu-latest
|
|
19
|
+
target: aarch64-unknown-linux-gnu
|
|
20
|
+
- os: macos-latest
|
|
21
|
+
target: x86_64-apple-darwin
|
|
22
|
+
- os: macos-latest
|
|
23
|
+
target: aarch64-apple-darwin
|
|
24
|
+
- os: windows-latest
|
|
25
|
+
target: x86_64-pc-windows-msvc
|
|
26
|
+
|
|
27
|
+
runs-on: ${{ matrix.os }}
|
|
28
|
+
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v4
|
|
31
|
+
|
|
32
|
+
- name: Install Rust
|
|
33
|
+
uses: dtolnay/rust-action@stable
|
|
34
|
+
with:
|
|
35
|
+
targets: ${{ matrix.target }}
|
|
36
|
+
|
|
37
|
+
- name: Install cross-compilation tools (Linux ARM64)
|
|
38
|
+
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
|
39
|
+
run: |
|
|
40
|
+
sudo apt-get update
|
|
41
|
+
sudo apt-get install -y gcc-aarch64-linux-gnu
|
|
42
|
+
echo 'CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc' >> $GITHUB_ENV
|
|
43
|
+
|
|
44
|
+
- name: Build
|
|
45
|
+
run: cargo build --release --target ${{ matrix.target }}
|
|
46
|
+
|
|
47
|
+
- name: Package binary
|
|
48
|
+
shell: bash
|
|
49
|
+
run: |
|
|
50
|
+
VERSION=${GITHUB_REF_NAME#v}
|
|
51
|
+
BIN_DIR=release-artifacts
|
|
52
|
+
mkdir -p $BIN_DIR
|
|
53
|
+
|
|
54
|
+
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
|
|
55
|
+
cp target/${{ matrix.target }}/release/anveesa.exe $BIN_DIR/anveesa.exe
|
|
56
|
+
cd $BIN_DIR && tar czf ../anveesa-$VERSION-${{ matrix.target }}.tar.gz anveesa.exe
|
|
57
|
+
else
|
|
58
|
+
cp target/${{ matrix.target }}/release/anveesa $BIN_DIR/anveesa
|
|
59
|
+
chmod +x $BIN_DIR/anveesa
|
|
60
|
+
cd $BIN_DIR && tar czf ../anveesa-$VERSION-${{ matrix.target }}.tar.gz anveesa
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
- name: Upload to Release
|
|
64
|
+
uses: softprops/action-gh-release@v2
|
|
65
|
+
with:
|
|
66
|
+
files: anveesa-*.tar.gz
|
|
67
|
+
env:
|
|
68
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
Anveesa is a Rust terminal wrapper for AI providers. It gives you one command,
|
|
4
4
|
`anveesa`, while each provider is configured as either:
|
|
5
5
|
|
|
6
|
+
## 📦 Publishing to npm
|
|
7
|
+
|
|
8
|
+
Anveesa can be published as an npm package via a Node.js wrapper that invokes the Rust binary.
|
|
9
|
+
|
|
10
|
+
### Install from npm
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g anveesa-cli
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Build and publish
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm run build
|
|
20
|
+
npm publish
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
See `package.json` in the root directory for build scripts and npm configuration.
|
|
24
|
+
|
|
6
25
|
- `openai-compatible`: HTTP chat completions providers such as OpenRouter and other compatible gateways.
|
|
7
26
|
- `command`: local CLIs such as Codex, Copilot, and Claude Code, where Anveesa spawns a command and passes the prompt.
|
|
8
27
|
|
|
@@ -148,6 +167,28 @@ OpenAI-compatible API providers:
|
|
|
148
167
|
|
|
149
168
|
- `openai`
|
|
150
169
|
- `sumopod`
|
|
170
|
+
|
|
171
|
+
### How to change the model
|
|
172
|
+
|
|
173
|
+
You can change the model using these commands:
|
|
174
|
+
|
|
175
|
+
**Via config file:**
|
|
176
|
+
```bash
|
|
177
|
+
anveesa config set-model "your-model"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Via command line:**
|
|
181
|
+
```bash
|
|
182
|
+
anveesa --model "your-model" "your prompt"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Interactive mode:**
|
|
186
|
+
```bash
|
|
187
|
+
anveesa
|
|
188
|
+
# Then select/change model in the prompt
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The model can be set per provider (e.g., `sumopod`, `openai`, `openrouter`, etc.) and can be overridden per command with `--model`.
|
|
151
192
|
- `openrouter`
|
|
152
193
|
- `glm`
|
|
153
194
|
- `glm-coding`
|
package/bin/anveesa
ADDED
|
Binary file
|
package/bin/anveesa.js
CHANGED
|
@@ -1,37 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
|
|
7
|
-
// Cari binary di beberapa kemungkinan lokasi
|
|
8
7
|
function findBinary() {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
path.join(__dirname, 'target', 'release', 'anveesa'),
|
|
12
|
-
path.join(__dirname, 'target', 'debug', 'anveesa'),
|
|
13
|
-
// Installed via npm local (node_modules/.bin)
|
|
14
|
-
path.join(__dirname, '..', '..', 'target', 'release', 'anveesa'),
|
|
15
|
-
path.join(__dirname, '..', '..', 'target', 'debug', 'anveesa'),
|
|
16
|
-
// Development mode (cargo build)
|
|
17
|
-
path.join(process.cwd(), 'target', 'release', 'anveesa'),
|
|
18
|
-
path.join(process.cwd(), 'target', 'debug', 'anveesa'),
|
|
19
|
-
];
|
|
8
|
+
const platform = process.platform;
|
|
9
|
+
const ext = platform === 'win32' ? '.exe' : '';
|
|
20
10
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
}
|
|
11
|
+
// Binary is shipped in the package bin/ directory
|
|
12
|
+
const bundled = path.join(__dirname, 'anveesa' + ext);
|
|
13
|
+
if (fs.existsSync(bundled)) return bundled;
|
|
26
14
|
|
|
27
|
-
//
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
15
|
+
// Also check for target/release (dev mode)
|
|
16
|
+
const devPath = path.join(__dirname, '..', 'target', 'release', 'anveesa' + ext);
|
|
17
|
+
if (fs.existsSync(devPath)) return devPath;
|
|
18
|
+
|
|
19
|
+
// Check if there's a sibling directory with the binary
|
|
20
|
+
const sibling = path.join(__dirname, 'target', 'release', 'anveesa' + ext);
|
|
21
|
+
if (fs.existsSync(sibling)) return sibling;
|
|
35
22
|
|
|
36
23
|
return null;
|
|
37
24
|
}
|
|
@@ -41,24 +28,23 @@ const binaryPath = findBinary();
|
|
|
41
28
|
if (!binaryPath) {
|
|
42
29
|
console.error('❌ anveesa binary not found.');
|
|
43
30
|
console.error('');
|
|
44
|
-
console.error('
|
|
45
|
-
console.error('
|
|
46
|
-
console.error(' # or: cargo build --release');
|
|
47
|
-
console.error('');
|
|
31
|
+
console.error('Try reinstalling: npm install -g anveesa');
|
|
32
|
+
console.error('Or build from source: cargo build --release');
|
|
48
33
|
process.exit(1);
|
|
49
34
|
}
|
|
50
35
|
|
|
51
|
-
// Spawn the Rust binary
|
|
52
36
|
const args = process.argv.slice(2);
|
|
53
|
-
const
|
|
37
|
+
const child = spawn(binaryPath, args, {
|
|
54
38
|
cwd: process.cwd(),
|
|
55
|
-
stdio:
|
|
39
|
+
stdio: 'inherit',
|
|
56
40
|
env: { ...process.env },
|
|
57
|
-
};
|
|
41
|
+
});
|
|
58
42
|
|
|
59
|
-
|
|
60
|
-
execFile(binaryPath, args, options);
|
|
61
|
-
} catch (error) {
|
|
43
|
+
child.on('error', (error) => {
|
|
62
44
|
console.error('Error running anveesa:', error.message);
|
|
63
45
|
process.exit(1);
|
|
64
|
-
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
child.on('exit', (code) => {
|
|
49
|
+
process.exit(code ?? 1);
|
|
50
|
+
});
|
package/package.json
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anveesa",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "A terminal CLI that wraps AI providers (OpenAI-compatible APIs and local CLIs) into a single unified command",
|
|
5
5
|
"main": "bin/anveesa.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"anveesa": "bin/anveesa.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "cargo build --release",
|
|
11
|
-
"
|
|
12
|
-
"prepublishOnly": "npm run build"
|
|
13
|
-
"install:bin": "cargo build --release"
|
|
11
|
+
"postinstall": "node scripts/install.js",
|
|
12
|
+
"prepublishOnly": "npm run build"
|
|
14
13
|
},
|
|
15
14
|
"files": [
|
|
16
15
|
"bin/",
|
|
@@ -18,15 +17,29 @@
|
|
|
18
17
|
"Cargo.toml",
|
|
19
18
|
"Cargo.lock",
|
|
20
19
|
"src/",
|
|
21
|
-
"README.md"
|
|
20
|
+
"README.md",
|
|
21
|
+
".github/"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"ai",
|
|
25
|
+
"cli",
|
|
26
|
+
"terminal",
|
|
27
|
+
"rust",
|
|
28
|
+
"openai",
|
|
29
|
+
"agent",
|
|
30
|
+
"copilot"
|
|
22
31
|
],
|
|
23
|
-
"keywords": ["ai", "cli", "terminal", "rust"],
|
|
24
32
|
"license": "MIT",
|
|
25
33
|
"engines": {
|
|
26
34
|
"node": ">=16"
|
|
27
35
|
},
|
|
28
36
|
"repository": {
|
|
29
37
|
"type": "git",
|
|
30
|
-
"url": "https://github.com/pandhuwibowo/anveesa-cli.git"
|
|
31
|
-
}
|
|
38
|
+
"url": "git+https://github.com/pandhuwibowo/anveesa-cli.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/pandhuwibowo/anveesa-cli",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/pandhuwibowo/anveesa-cli/issues"
|
|
43
|
+
},
|
|
44
|
+
"author": "Anveesa"
|
|
32
45
|
}
|
package/scripts/install.js
CHANGED
|
@@ -7,49 +7,141 @@
|
|
|
7
7
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
|
+
const https = require('https');
|
|
10
11
|
const { execSync } = require('child_process');
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
13
|
+
const PACKAGE = require(path.join(__dirname, '..', 'package.json'));
|
|
14
|
+
const REPO = 'pandhuwibowo/anveesa-cli';
|
|
15
|
+
const BIN_DIR = path.join(__dirname, 'bin');
|
|
16
|
+
|
|
17
|
+
function getPlatformInfo() {
|
|
18
|
+
const platform = process.platform;
|
|
19
|
+
const arch = process.arch;
|
|
20
|
+
|
|
21
|
+
if (platform === 'darwin' && arch === 'arm64')
|
|
22
|
+
return { platform: 'macos', target: 'aarch64-apple-darwin', ext: '' };
|
|
23
|
+
if (platform === 'darwin' && arch === 'x64')
|
|
24
|
+
return { platform: 'macos', target: 'x86_64-apple-darwin', ext: '' };
|
|
25
|
+
if (platform === 'linux' && arch === 'x64')
|
|
26
|
+
return { platform: 'linux', target: 'x86_64-unknown-linux-gnu', ext: '' };
|
|
27
|
+
if (platform === 'linux' && arch === 'arm64')
|
|
28
|
+
return { platform: 'linux', target: 'aarch64-unknown-linux-gnu', ext: '' };
|
|
29
|
+
if (platform === 'win32' && arch === 'x64')
|
|
30
|
+
return { platform: 'windows', target: 'x86_64-pc-windows-msvc', ext: '.exe' };
|
|
31
|
+
|
|
27
32
|
return null;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
function
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
function getBinaryUrl() {
|
|
36
|
+
const info = getPlatformInfo();
|
|
37
|
+
if (!info) return null;
|
|
38
|
+
const version = PACKAGE.version;
|
|
39
|
+
return `https://github.com/${REPO}/releases/download/v${version}/anveesa-${version}-${info.target}.tar.gz`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function download(url, dest) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const file = fs.createWriteStream(dest);
|
|
45
|
+
https.get(url, { followAllRedirects: true }, (res) => {
|
|
46
|
+
if (res.statusCode === 302 || res.statusCode === 301) {
|
|
47
|
+
https.get(res.headers.location, (res2) => {
|
|
48
|
+
res2.pipe(file);
|
|
49
|
+
file.on('finish', () => { file.close(); resolve(); });
|
|
50
|
+
}).on('error', reject);
|
|
51
|
+
} else if (res.statusCode === 200) {
|
|
52
|
+
res.pipe(file);
|
|
53
|
+
file.on('finish', () => { file.close(); resolve(); });
|
|
54
|
+
} else if (res.statusCode === 404) {
|
|
55
|
+
file.close();
|
|
56
|
+
fs.unlink(dest, () => {});
|
|
57
|
+
reject(new Error('404'));
|
|
58
|
+
} else {
|
|
59
|
+
file.close();
|
|
60
|
+
fs.unlink(dest, () => {});
|
|
61
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
62
|
+
}
|
|
63
|
+
}).on('error', (err) => {
|
|
64
|
+
fs.unlink(dest, () => {});
|
|
65
|
+
reject(err);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function tryDownloadBinary() {
|
|
71
|
+
const url = getBinaryUrl();
|
|
72
|
+
if (!url) {
|
|
73
|
+
console.log('⚠ Unsupported platform:', process.platform, process.arch);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log('⬇ Downloading prebuilt binary for', process.platform, process.arch);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const tarPath = path.join(__dirname, 'anveesa-bin.tar.gz');
|
|
81
|
+
await download(url, tarPath);
|
|
82
|
+
|
|
83
|
+
// Extract
|
|
84
|
+
execSync(`tar xzf "${tarPath}" -C "${BIN_DIR}"`, { stdio: 'inherit' });
|
|
85
|
+
fs.unlinkSync(tarPath);
|
|
86
|
+
|
|
87
|
+
// Make executable
|
|
88
|
+
const binary = path.join(BIN_DIR, 'anveesa');
|
|
89
|
+
if (fs.existsSync(binary)) {
|
|
90
|
+
fs.chmodSync(binary, 0o755);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log('✓ Binary downloaded successfully');
|
|
36
94
|
return true;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
console.error(' npm run install:bin');
|
|
42
|
-
console.error('');
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e.message === '404') {
|
|
97
|
+
console.log('ℹ No prebuilt binary for this version yet');
|
|
98
|
+
}
|
|
43
99
|
return false;
|
|
44
100
|
}
|
|
45
101
|
}
|
|
46
102
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
103
|
+
function tryBuildFromSource() {
|
|
104
|
+
try {
|
|
105
|
+
console.log('⚙ Building from source (requires Rust)...');
|
|
106
|
+
execSync('cargo build --release', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
|
|
107
|
+
|
|
108
|
+
const src = path.join(__dirname, '..', 'target', 'release', 'anveesa');
|
|
109
|
+
const dest = path.join(BIN_DIR, 'anveesa');
|
|
110
|
+
|
|
111
|
+
if (fs.existsSync(src)) {
|
|
112
|
+
if (!fs.existsSync(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
113
|
+
fs.copyFileSync(src, dest);
|
|
114
|
+
fs.chmodSync(dest, 0o755);
|
|
115
|
+
console.log('✓ Built from source successfully');
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.log('⚠ Build from source failed (Rust not installed?)');
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function install() {
|
|
125
|
+
// Check if binary already exists
|
|
126
|
+
const existing = path.join(BIN_DIR, 'anveesa');
|
|
127
|
+
const existingExe = path.join(BIN_DIR, 'anveesa.exe');
|
|
128
|
+
if (fs.existsSync(existing) || fs.existsSync(existingExe)) {
|
|
129
|
+
console.log('✓ anveesa binary already installed');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Try download first
|
|
134
|
+
if (await tryDownloadBinary()) return;
|
|
135
|
+
|
|
136
|
+
// Fallback to build from source
|
|
137
|
+
if (tryBuildFromSource()) return;
|
|
138
|
+
|
|
139
|
+
console.error('');
|
|
140
|
+
console.error('✗ Could not install anveesa binary.');
|
|
141
|
+
console.error('');
|
|
142
|
+
console.error('Install Rust: https://rustup.rs/');
|
|
143
|
+
console.error('Then run: npm install -g anveesa');
|
|
144
|
+
process.exit(1);
|
|
50
145
|
}
|
|
51
146
|
|
|
52
|
-
|
|
53
|
-
getBinaryPath,
|
|
54
|
-
checkAndInstall
|
|
55
|
-
};
|
|
147
|
+
install();
|
|
@@ -46,6 +46,7 @@ pub async fn ask(
|
|
|
46
46
|
|
|
47
47
|
let client = reqwest::Client::builder()
|
|
48
48
|
.connect_timeout(CONNECT_TIMEOUT)
|
|
49
|
+
.read_timeout(Duration::from_secs(300)) // 5-minute read timeout for long streams
|
|
49
50
|
.build()
|
|
50
51
|
.context("failed to build HTTP client")?;
|
|
51
52
|
let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/'));
|
|
@@ -662,22 +663,42 @@ async fn stream_response(
|
|
|
662
663
|
events: &UnboundedSender<StreamEvent>,
|
|
663
664
|
) -> Result<()> {
|
|
664
665
|
let mut buffer = String::new();
|
|
666
|
+
let mut consecutive_errors: usize = 0;
|
|
667
|
+
const MAX_CONSECUTIVE_ERRORS: usize = 3;
|
|
665
668
|
|
|
666
|
-
|
|
667
|
-
.chunk()
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
669
|
+
loop {
|
|
670
|
+
let chunk_result = response.chunk().await;
|
|
671
|
+
|
|
672
|
+
match chunk_result {
|
|
673
|
+
Ok(Some(chunk)) => {
|
|
674
|
+
consecutive_errors = 0; // Reset error counter on successful read
|
|
675
|
+
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
|
676
|
+
|
|
677
|
+
while let Some(newline) = buffer.find('\n') {
|
|
678
|
+
let line: String = buffer.drain(..=newline).collect();
|
|
679
|
+
if let Some(token) = state.ingest_line(line.trim_end_matches(['\r', '\n'])) {
|
|
680
|
+
let _ = events.send(StreamEvent::Token(token));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
Ok(None) => {
|
|
685
|
+
// Stream ended normally
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
Err(_e) => {
|
|
689
|
+
consecutive_errors += 1;
|
|
690
|
+
if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
|
|
691
|
+
// Log the error but don't fail the whole request
|
|
692
|
+
eprintln!("\n[warning: stream interrupted after {} consecutive errors]", consecutive_errors);
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
// Try to continue reading - transient network hiccups happen
|
|
696
|
+
continue;
|
|
677
697
|
}
|
|
678
698
|
}
|
|
679
699
|
}
|
|
680
700
|
|
|
701
|
+
// Process any remaining data in the buffer
|
|
681
702
|
if !buffer.is_empty()
|
|
682
703
|
&& let Some(token) = state.ingest_line(buffer.trim())
|
|
683
704
|
{
|
|
@@ -716,7 +737,10 @@ impl StreamState {
|
|
|
716
737
|
self.done = true;
|
|
717
738
|
return None;
|
|
718
739
|
}
|
|
719
|
-
let chunk: Value = serde_json::from_str(data)
|
|
740
|
+
let chunk: Value = match serde_json::from_str(data) {
|
|
741
|
+
Ok(v) => v,
|
|
742
|
+
Err(_) => return None, // Skip malformed lines instead of bailing
|
|
743
|
+
};
|
|
720
744
|
self.apply_chunk(&chunk)
|
|
721
745
|
}
|
|
722
746
|
|
|
@@ -725,7 +749,15 @@ impl StreamState {
|
|
|
725
749
|
self.usage = parse_usage(usage);
|
|
726
750
|
}
|
|
727
751
|
|
|
728
|
-
let
|
|
752
|
+
let Some(choices) = chunk.get("choices") else {
|
|
753
|
+
return None;
|
|
754
|
+
};
|
|
755
|
+
let Some(first_choice) = choices.get(0) else {
|
|
756
|
+
return None;
|
|
757
|
+
};
|
|
758
|
+
let Some(delta) = first_choice.get("delta") else {
|
|
759
|
+
return None;
|
|
760
|
+
};
|
|
729
761
|
|
|
730
762
|
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
|
|
731
763
|
for call in tool_calls {
|
|
@@ -733,7 +765,8 @@ impl StreamState {
|
|
|
733
765
|
}
|
|
734
766
|
}
|
|
735
767
|
|
|
736
|
-
|
|
768
|
+
// Content may be absent (e.g., tool-call-only chunks) — don't bail.
|
|
769
|
+
let text = delta.get("content").and_then(Value::as_str).unwrap_or("");
|
|
737
770
|
if text.is_empty() {
|
|
738
771
|
return None;
|
|
739
772
|
}
|