unswallow 0.1.0__tar.gz
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.
- unswallow-0.1.0/PKG-INFO +85 -0
- unswallow-0.1.0/README.md +69 -0
- unswallow-0.1.0/pyproject.toml +47 -0
- unswallow-0.1.0/setup.cfg +4 -0
- unswallow-0.1.0/tests/test_core.py +234 -0
- unswallow-0.1.0/tests/test_fp_guard.py +147 -0
- unswallow-0.1.0/tests/test_history.py +65 -0
- unswallow-0.1.0/tests/test_semver.py +49 -0
- unswallow-0.1.0/tests/test_stream.py +173 -0
- unswallow-0.1.0/unswallow/__init__.py +51 -0
- unswallow-0.1.0/unswallow/classify.py +65 -0
- unswallow-0.1.0/unswallow/cli.py +257 -0
- unswallow-0.1.0/unswallow/confidence.py +91 -0
- unswallow-0.1.0/unswallow/data/engine-matrix.json +75 -0
- unswallow-0.1.0/unswallow/history.py +39 -0
- unswallow-0.1.0/unswallow/matrix.py +84 -0
- unswallow-0.1.0/unswallow/pipeline.py +98 -0
- unswallow-0.1.0/unswallow/recover.py +214 -0
- unswallow-0.1.0/unswallow/scan.py +64 -0
- unswallow-0.1.0/unswallow/semver.py +87 -0
- unswallow-0.1.0/unswallow/stream.py +169 -0
- unswallow-0.1.0/unswallow/types.py +79 -0
- unswallow-0.1.0/unswallow.egg-info/PKG-INFO +85 -0
- unswallow-0.1.0/unswallow.egg-info/SOURCES.txt +25 -0
- unswallow-0.1.0/unswallow.egg-info/dependency_links.txt +1 -0
- unswallow-0.1.0/unswallow.egg-info/entry_points.txt +2 -0
- unswallow-0.1.0/unswallow.egg-info/top_level.txt +1 -0
unswallow-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unswallow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detect and recover tool calls trapped inside a model's reasoning channel — the reasoning-channel swallow bug class across vLLM, SGLang, and llama.cpp. 1:1 mirror of the TypeScript library.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/0DukePan/unswallow
|
|
7
|
+
Project-URL: Repository, https://github.com/0DukePan/unswallow
|
|
8
|
+
Project-URL: Issues, https://github.com/0DukePan/unswallow/issues
|
|
9
|
+
Keywords: llm,agents,tool-calls,tool-use,function-calling,reasoning,thinking,vllm,sglang,llama.cpp,qwen3,deepseek,openai-compatible
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# unswallow (Python)
|
|
18
|
+
|
|
19
|
+
The 1:1 Python mirror of the `unswallow` TypeScript library: detect and recover tool calls trapped inside a model's reasoning channel — the reasoning-channel swallow bug class across vLLM, SGLang, and llama.cpp.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install unswallow
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from unswallow import check_and_rescue
|
|
27
|
+
|
|
28
|
+
result = check_and_rescue(
|
|
29
|
+
raw_provider_response,
|
|
30
|
+
engine_hint="vllm",
|
|
31
|
+
engine_version="0.19.0",
|
|
32
|
+
tool_schemas=my_tools,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if result.recovered and result.recovered_response:
|
|
36
|
+
return result.recovered_response # tool_calls is populated, finish_reason is "tool_calls"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Same interface, same semantics, same bundled matrix data (synced from `packages/matrix/data/engine-matrix.json`), zero runtime dependencies, Python 3.9+.
|
|
40
|
+
|
|
41
|
+
## Streaming
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from unswallow import check_and_rescue_stream
|
|
45
|
+
|
|
46
|
+
result = await check_and_rescue_stream(
|
|
47
|
+
chunk_iterable, # AsyncIterable of OpenAI-compatible SSE chunks
|
|
48
|
+
engine_hint="vllm",
|
|
49
|
+
engine_version="0.19.0",
|
|
50
|
+
on_leak=logger.warning, # live pattern-C signal, optional
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or accumulate manually with `StreamAccumulator` (`.push(chunk)` / `.end()`).
|
|
55
|
+
|
|
56
|
+
## History hygiene (Pattern D)
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from unswallow import sanitize_history, strip_reasoning_tags
|
|
60
|
+
|
|
61
|
+
clean = sanitize_history(messages)
|
|
62
|
+
text = strip_reasoning_tags(assistant_text) # "< thinking>\nplan\n< response>\nanswer" -> "answer"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## CLI
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
unswallow check # bundled self-test demo (vLLM #39056)
|
|
69
|
+
unswallow check --fixture response.json # captured raw response
|
|
70
|
+
unswallow check --endpoint http://localhost:8000/v1 --model Qwen/Qwen3.5-35B-A3B-FP8 --engine vllm --version 0.19.0
|
|
71
|
+
unswallow matrix
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Exit codes for live probes: `0` = not affected, `1` = affected, `2` = error.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
cd packages/python
|
|
80
|
+
python -m unittest discover -s tests -v # stdlib-only test suite
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The engine matrix is bundled as data — after editing `packages/matrix/data/engine-matrix.json`, run `python packages/python/scripts/sync_matrix.py` from the repo root. CI verifies the copies stay identical.
|
|
84
|
+
|
|
85
|
+
Everything else — patterns, confidence scoring, the false-positive guard, the benchmark corpus and the engine matrix — is documented in the repository root [README](../../README.md).
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# unswallow (Python)
|
|
2
|
+
|
|
3
|
+
The 1:1 Python mirror of the `unswallow` TypeScript library: detect and recover tool calls trapped inside a model's reasoning channel — the reasoning-channel swallow bug class across vLLM, SGLang, and llama.cpp.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install unswallow
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from unswallow import check_and_rescue
|
|
11
|
+
|
|
12
|
+
result = check_and_rescue(
|
|
13
|
+
raw_provider_response,
|
|
14
|
+
engine_hint="vllm",
|
|
15
|
+
engine_version="0.19.0",
|
|
16
|
+
tool_schemas=my_tools,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if result.recovered and result.recovered_response:
|
|
20
|
+
return result.recovered_response # tool_calls is populated, finish_reason is "tool_calls"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Same interface, same semantics, same bundled matrix data (synced from `packages/matrix/data/engine-matrix.json`), zero runtime dependencies, Python 3.9+.
|
|
24
|
+
|
|
25
|
+
## Streaming
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from unswallow import check_and_rescue_stream
|
|
29
|
+
|
|
30
|
+
result = await check_and_rescue_stream(
|
|
31
|
+
chunk_iterable, # AsyncIterable of OpenAI-compatible SSE chunks
|
|
32
|
+
engine_hint="vllm",
|
|
33
|
+
engine_version="0.19.0",
|
|
34
|
+
on_leak=logger.warning, # live pattern-C signal, optional
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or accumulate manually with `StreamAccumulator` (`.push(chunk)` / `.end()`).
|
|
39
|
+
|
|
40
|
+
## History hygiene (Pattern D)
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from unswallow import sanitize_history, strip_reasoning_tags
|
|
44
|
+
|
|
45
|
+
clean = sanitize_history(messages)
|
|
46
|
+
text = strip_reasoning_tags(assistant_text) # "< thinking>\nplan\n< response>\nanswer" -> "answer"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## CLI
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
unswallow check # bundled self-test demo (vLLM #39056)
|
|
53
|
+
unswallow check --fixture response.json # captured raw response
|
|
54
|
+
unswallow check --endpoint http://localhost:8000/v1 --model Qwen/Qwen3.5-35B-A3B-FP8 --engine vllm --version 0.19.0
|
|
55
|
+
unswallow matrix
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Exit codes for live probes: `0` = not affected, `1` = affected, `2` = error.
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
cd packages/python
|
|
64
|
+
python -m unittest discover -s tests -v # stdlib-only test suite
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The engine matrix is bundled as data — after editing `packages/matrix/data/engine-matrix.json`, run `python packages/python/scripts/sync_matrix.py` from the repo root. CI verifies the copies stay identical.
|
|
68
|
+
|
|
69
|
+
Everything else — patterns, confidence scoring, the false-positive guard, the benchmark corpus and the engine matrix — is documented in the repository root [README](../../README.md).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unswallow"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Detect and recover tool calls trapped inside a model's reasoning channel — the reasoning-channel swallow bug class across vLLM, SGLang, and llama.cpp. 1:1 mirror of the TypeScript library."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = [
|
|
13
|
+
"llm",
|
|
14
|
+
"agents",
|
|
15
|
+
"tool-calls",
|
|
16
|
+
"tool-use",
|
|
17
|
+
"function-calling",
|
|
18
|
+
"reasoning",
|
|
19
|
+
"thinking",
|
|
20
|
+
"vllm",
|
|
21
|
+
"sglang",
|
|
22
|
+
"llama.cpp",
|
|
23
|
+
"qwen3",
|
|
24
|
+
"deepseek",
|
|
25
|
+
"openai-compatible",
|
|
26
|
+
]
|
|
27
|
+
classifiers = [
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
32
|
+
]
|
|
33
|
+
dependencies = []
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/0DukePan/unswallow"
|
|
37
|
+
Repository = "https://github.com/0DukePan/unswallow"
|
|
38
|
+
Issues = "https://github.com/0DukePan/unswallow/issues"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
unswallow = "unswallow.cli:main"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools]
|
|
44
|
+
packages = ["unswallow"]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.package-data]
|
|
47
|
+
unswallow = ["data/*.json"]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from unswallow import check_and_rescue, match_matrix_entry, normalize_engine
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def response(message, finish_reason="stop"):
|
|
7
|
+
return {
|
|
8
|
+
"id": "chatcmpl-test",
|
|
9
|
+
"object": "chat.completion",
|
|
10
|
+
"model": "test-model",
|
|
11
|
+
"choices": [{"index": 0, "finish_reason": finish_reason, "message": message}],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CoreTest(unittest.TestCase):
|
|
16
|
+
def test_pattern_a_function_xml_in_reasoning(self):
|
|
17
|
+
r = response(
|
|
18
|
+
{
|
|
19
|
+
"role": "assistant",
|
|
20
|
+
"content": "",
|
|
21
|
+
"reasoning": (
|
|
22
|
+
"< thinking>\nI need to answer the user\u2019s question. The answer is 204.\n"
|
|
23
|
+
"<tool_call>\n<function=Finish>\n<parameter=answer>\n204\n</parameter>\n</function>\n"
|
|
24
|
+
"</tool_call>\n< response>\n"
|
|
25
|
+
),
|
|
26
|
+
"tool_calls": [],
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
|
|
30
|
+
self.assertTrue(result.detected)
|
|
31
|
+
self.assertEqual(result.pattern, "A")
|
|
32
|
+
self.assertEqual(result.source, "reasoning")
|
|
33
|
+
self.assertTrue(result.recovered)
|
|
34
|
+
self.assertEqual(result.confidence, 0.95)
|
|
35
|
+
self.assertEqual(result.tool_call.name, "Finish")
|
|
36
|
+
self.assertEqual(result.tool_call.arguments, {"answer": 204})
|
|
37
|
+
self.assertIsNotNone(result.recovered_response)
|
|
38
|
+
calls = result.recovered_response["choices"][0]["message"]["tool_calls"]
|
|
39
|
+
self.assertEqual(calls[0]["function"]["name"], "Finish")
|
|
40
|
+
self.assertEqual(calls[0]["function"]["arguments"], '{"answer": 204}')
|
|
41
|
+
self.assertEqual(result.recovered_response["choices"][0]["finish_reason"], "tool_calls")
|
|
42
|
+
|
|
43
|
+
def test_pattern_a_json_envelope_in_reasoning_content(self):
|
|
44
|
+
r = response(
|
|
45
|
+
{
|
|
46
|
+
"role": "assistant",
|
|
47
|
+
"content": "",
|
|
48
|
+
"reasoning_content": (
|
|
49
|
+
"< thinking>\nI should get the weather for Tokyo.\n"
|
|
50
|
+
'<tool_call>\n{"name": "get_weather", "arguments": {"city": "Tokyo"}}\n</tool_call>\n'
|
|
51
|
+
"< response>\n"
|
|
52
|
+
),
|
|
53
|
+
"tool_calls": [],
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
result = check_and_rescue(r, engine_hint="sglang", engine_version="0.4.6")
|
|
57
|
+
self.assertTrue(result.detected)
|
|
58
|
+
self.assertEqual(result.pattern, "A")
|
|
59
|
+
self.assertEqual(result.source, "reasoning_content")
|
|
60
|
+
self.assertEqual(result.tool_call.arguments, {"city": "Tokyo"})
|
|
61
|
+
self.assertEqual(result.confidence, 0.95)
|
|
62
|
+
|
|
63
|
+
def test_pattern_a_think_block_in_content(self):
|
|
64
|
+
r = response(
|
|
65
|
+
{
|
|
66
|
+
"role": "assistant",
|
|
67
|
+
"content": (
|
|
68
|
+
"<thinking>\nThe user wants the weather in Berlin.\n"
|
|
69
|
+
'<tool_call>\n{"name": "get_weather", "arguments": {"city": "Berlin"}}\n</tool_call>\n'
|
|
70
|
+
"</thinking>\n"
|
|
71
|
+
),
|
|
72
|
+
"tool_calls": [],
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
result = check_and_rescue(r, engine_hint="llama.cpp", engine_version="b8461")
|
|
76
|
+
self.assertTrue(result.detected)
|
|
77
|
+
self.assertEqual(result.pattern, "A")
|
|
78
|
+
self.assertEqual(result.source, "thinking")
|
|
79
|
+
self.assertEqual(result.tool_call.arguments, {"city": "Berlin"})
|
|
80
|
+
self.assertEqual(result.confidence, 0.95)
|
|
81
|
+
|
|
82
|
+
def test_pattern_b_trailing_text(self):
|
|
83
|
+
r = response(
|
|
84
|
+
{
|
|
85
|
+
"role": "assistant",
|
|
86
|
+
"content": (
|
|
87
|
+
'{"name": "get_weather", "arguments": {"city": "Beijing"}}\n\n'
|
|
88
|
+
"Let me also check whether there is any other useful information to report.\n"
|
|
89
|
+
),
|
|
90
|
+
"tool_calls": [],
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
result = check_and_rescue(r, engine_hint="llama.cpp", engine_version="b8461")
|
|
94
|
+
self.assertTrue(result.detected)
|
|
95
|
+
self.assertEqual(result.pattern, "B")
|
|
96
|
+
self.assertEqual(result.source, "content")
|
|
97
|
+
self.assertTrue(result.recovered)
|
|
98
|
+
self.assertEqual(result.confidence, 0.55)
|
|
99
|
+
self.assertTrue(any(w.startswith("trailing text") for w in result.warnings))
|
|
100
|
+
|
|
101
|
+
def test_pattern_b_complete_json_in_content(self):
|
|
102
|
+
r = response(
|
|
103
|
+
{
|
|
104
|
+
"role": "assistant",
|
|
105
|
+
"content": '{"name": "get_weather", "arguments": {"city": "Paris"}}',
|
|
106
|
+
"tool_calls": [],
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
result = check_and_rescue(r, engine_hint="vllm", engine_version="0.26.0")
|
|
110
|
+
self.assertTrue(result.detected)
|
|
111
|
+
self.assertEqual(result.pattern, "B")
|
|
112
|
+
self.assertTrue(result.recovered)
|
|
113
|
+
|
|
114
|
+
def test_pattern_c_leak_detection_only(self):
|
|
115
|
+
r = response(
|
|
116
|
+
{
|
|
117
|
+
"role": "assistant",
|
|
118
|
+
"content": "Here is the answer. <mm:think>I should verify the weather data first",
|
|
119
|
+
"tool_calls": [],
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
result = check_and_rescue(r)
|
|
123
|
+
self.assertTrue(result.detected)
|
|
124
|
+
self.assertEqual(result.pattern, "C")
|
|
125
|
+
self.assertFalse(result.recovered)
|
|
126
|
+
self.assertLessEqual(result.confidence, 0.5)
|
|
127
|
+
|
|
128
|
+
def test_healthy_response_untouched(self):
|
|
129
|
+
r = response(
|
|
130
|
+
{
|
|
131
|
+
"role": "assistant",
|
|
132
|
+
"content": "",
|
|
133
|
+
"reasoning": "< thinking>\nI will call get_weather.\n< response>\n",
|
|
134
|
+
"tool_calls": [
|
|
135
|
+
{
|
|
136
|
+
"id": "call_abc",
|
|
137
|
+
"type": "function",
|
|
138
|
+
"function": {"name": "get_weather", "arguments": '{"city": "Tokyo"}'},
|
|
139
|
+
}
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
"tool_calls",
|
|
143
|
+
)
|
|
144
|
+
result = check_and_rescue(r, engine_hint="vllm", engine_version="0.24.0")
|
|
145
|
+
self.assertFalse(result.detected)
|
|
146
|
+
self.assertEqual(result.confidence, 0.0)
|
|
147
|
+
self.assertIsNone(result.recovered_response)
|
|
148
|
+
|
|
149
|
+
def test_resolved_range_warns(self):
|
|
150
|
+
r = response(
|
|
151
|
+
{
|
|
152
|
+
"role": "assistant",
|
|
153
|
+
"content": "",
|
|
154
|
+
"reasoning": (
|
|
155
|
+
"< thinking>\n"
|
|
156
|
+
'<tool_call>\n{"name": "get_weather", "arguments": {"city": "Tokyo"}}\n</tool_call>\n'
|
|
157
|
+
"< response>\n"
|
|
158
|
+
),
|
|
159
|
+
"tool_calls": [],
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
result = check_and_rescue(r, engine_hint="vllm", engine_version="0.25.0")
|
|
163
|
+
self.assertTrue(result.detected)
|
|
164
|
+
self.assertEqual(result.confidence, 0.6)
|
|
165
|
+
self.assertIsNotNone(result.matrix_match)
|
|
166
|
+
self.assertEqual(result.matrix_match.behavior, "resolved")
|
|
167
|
+
self.assertTrue(any("resolved" in w for w in result.warnings))
|
|
168
|
+
|
|
169
|
+
def test_unknown_engine_heuristic(self):
|
|
170
|
+
r = response(
|
|
171
|
+
{
|
|
172
|
+
"role": "assistant",
|
|
173
|
+
"content": "",
|
|
174
|
+
"reasoning": (
|
|
175
|
+
"< thinking>\n"
|
|
176
|
+
'{"name": "get_weather", "arguments": {"city": "Rome"}}\n'
|
|
177
|
+
"< response>\n"
|
|
178
|
+
),
|
|
179
|
+
"tool_calls": [],
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
result = check_and_rescue(r)
|
|
183
|
+
self.assertTrue(result.detected)
|
|
184
|
+
self.assertEqual(result.pattern, "A")
|
|
185
|
+
self.assertEqual(result.confidence, 0.55)
|
|
186
|
+
self.assertEqual(result.engine_hint, "unknown")
|
|
187
|
+
self.assertIsNone(result.matrix_match)
|
|
188
|
+
self.assertTrue(any("engine_hint" in w for w in result.warnings))
|
|
189
|
+
|
|
190
|
+
def test_tool_schemas_mismatch_lowers_confidence(self):
|
|
191
|
+
r = response(
|
|
192
|
+
{
|
|
193
|
+
"role": "assistant",
|
|
194
|
+
"content": "",
|
|
195
|
+
"reasoning": (
|
|
196
|
+
"< thinking>\n"
|
|
197
|
+
'{"name": "get_weather", "arguments": {"city": "Oslo"}}\n'
|
|
198
|
+
"< response>\n"
|
|
199
|
+
),
|
|
200
|
+
"tool_calls": [],
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
result = check_and_rescue(
|
|
204
|
+
r,
|
|
205
|
+
engine_hint="vllm",
|
|
206
|
+
engine_version="0.19.0",
|
|
207
|
+
tool_schemas=[
|
|
208
|
+
{
|
|
209
|
+
"type": "function",
|
|
210
|
+
"function": {"name": "get_news", "parameters": {"type": "object", "properties": {}}},
|
|
211
|
+
}
|
|
212
|
+
],
|
|
213
|
+
)
|
|
214
|
+
self.assertEqual(result.confidence, 0.85)
|
|
215
|
+
self.assertTrue(any("not found in provided tool_schemas" in w for w in result.warnings))
|
|
216
|
+
|
|
217
|
+
def test_missing_choices(self):
|
|
218
|
+
result = check_and_rescue({"choices": []})
|
|
219
|
+
self.assertFalse(result.detected)
|
|
220
|
+
self.assertEqual(result.confidence, 0.0)
|
|
221
|
+
self.assertTrue(result.warnings)
|
|
222
|
+
|
|
223
|
+
def test_normalize_engine(self):
|
|
224
|
+
self.assertEqual(normalize_engine("vllm"), "vllm")
|
|
225
|
+
self.assertEqual(normalize_engine("sglang"), "sglang")
|
|
226
|
+
self.assertEqual(normalize_engine("llama.cpp"), "llama.cpp")
|
|
227
|
+
self.assertEqual(normalize_engine("llama-cpp"), "llama.cpp")
|
|
228
|
+
self.assertEqual(normalize_engine("LLAMA.CPP"), "llama.cpp")
|
|
229
|
+
self.assertEqual(normalize_engine("anything-else"), "unknown")
|
|
230
|
+
self.assertEqual(normalize_engine(None), "unknown")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
unittest.main()
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from unswallow import check_and_rescue
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def response(message):
|
|
7
|
+
return {
|
|
8
|
+
"id": "chatcmpl-fp",
|
|
9
|
+
"object": "chat.completion",
|
|
10
|
+
"model": "test-model",
|
|
11
|
+
"choices": [{"index": 0, "finish_reason": "stop", "message": message}],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FpGuardTest(unittest.TestCase):
|
|
16
|
+
def test_discussion_only_never_recovered(self):
|
|
17
|
+
r = response(
|
|
18
|
+
{
|
|
19
|
+
"role": "assistant",
|
|
20
|
+
"content": "",
|
|
21
|
+
"reasoning": (
|
|
22
|
+
"< thinking>\nI could call get_weather to check the weather in Tokyo, but I do not "
|
|
23
|
+
"need it for this answer. The user only asked a general question.\n< response>\n"
|
|
24
|
+
),
|
|
25
|
+
"tool_calls": [],
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
|
|
29
|
+
self.assertFalse(result.detected)
|
|
30
|
+
self.assertEqual(result.confidence, 0.0)
|
|
31
|
+
self.assertIsNone(result.recovered_response)
|
|
32
|
+
|
|
33
|
+
def test_partial_json_without_arguments(self):
|
|
34
|
+
r = response(
|
|
35
|
+
{
|
|
36
|
+
"role": "assistant",
|
|
37
|
+
"content": "",
|
|
38
|
+
"reasoning": (
|
|
39
|
+
"< thinking>\nI might call {\"name\": \"get_weather\" if needed.\n< response>\n"
|
|
40
|
+
),
|
|
41
|
+
"tool_calls": [],
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
|
|
45
|
+
self.assertFalse(result.detected)
|
|
46
|
+
|
|
47
|
+
def test_non_object_arguments_rejected(self):
|
|
48
|
+
r = response(
|
|
49
|
+
{
|
|
50
|
+
"role": "assistant",
|
|
51
|
+
"content": "",
|
|
52
|
+
"reasoning": (
|
|
53
|
+
"< thinking>\n"
|
|
54
|
+
'<tool_call>\n{"name": "get_weather", "arguments": "Tokyo"}\n</tool_call>\n'
|
|
55
|
+
"< response>\n"
|
|
56
|
+
),
|
|
57
|
+
"tool_calls": [],
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
|
|
61
|
+
self.assertFalse(result.detected)
|
|
62
|
+
|
|
63
|
+
def test_missing_name_rejected(self):
|
|
64
|
+
r = response(
|
|
65
|
+
{
|
|
66
|
+
"role": "assistant",
|
|
67
|
+
"content": "",
|
|
68
|
+
"reasoning": (
|
|
69
|
+
"< thinking>\n"
|
|
70
|
+
'<tool_call>\n{"arguments": {"city": "Tokyo"}}\n</tool_call>\n'
|
|
71
|
+
"< response>\n"
|
|
72
|
+
),
|
|
73
|
+
"tool_calls": [],
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
|
|
77
|
+
self.assertFalse(result.detected)
|
|
78
|
+
|
|
79
|
+
def test_unclosed_xml_with_broken_json_rejected(self):
|
|
80
|
+
r = response(
|
|
81
|
+
{
|
|
82
|
+
"role": "assistant",
|
|
83
|
+
"content": "",
|
|
84
|
+
"reasoning": (
|
|
85
|
+
"< thinking>\nI wonder if <tool_call>\n"
|
|
86
|
+
'{"name": "get_weather", "arguments": {"city": "Tokyo"}\n'
|
|
87
|
+
"is the right thing to do.\n< response>\n"
|
|
88
|
+
),
|
|
89
|
+
"tool_calls": [],
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
|
|
93
|
+
self.assertFalse(result.detected)
|
|
94
|
+
|
|
95
|
+
def test_empty_name_function_rejected(self):
|
|
96
|
+
r = response(
|
|
97
|
+
{
|
|
98
|
+
"role": "assistant",
|
|
99
|
+
"content": "",
|
|
100
|
+
"reasoning": (
|
|
101
|
+
"< thinking>\n<function=>\n<parameter=answer>204</parameter>\n</function>\n"
|
|
102
|
+
"< response>\n"
|
|
103
|
+
),
|
|
104
|
+
"tool_calls": [],
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
|
|
108
|
+
self.assertFalse(result.detected)
|
|
109
|
+
|
|
110
|
+
def test_real_recovery_with_matching_schema(self):
|
|
111
|
+
r = response(
|
|
112
|
+
{
|
|
113
|
+
"role": "assistant",
|
|
114
|
+
"content": "",
|
|
115
|
+
"reasoning": (
|
|
116
|
+
"< thinking>\n"
|
|
117
|
+
'<tool_call>\n{"name": "get_weather", "arguments": {"city": "Tokyo"}}\n</tool_call>\n'
|
|
118
|
+
"< response>\n"
|
|
119
|
+
),
|
|
120
|
+
"tool_calls": [],
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
result = check_and_rescue(
|
|
124
|
+
r,
|
|
125
|
+
engine_hint="vllm",
|
|
126
|
+
engine_version="0.19.0",
|
|
127
|
+
tool_schemas=[
|
|
128
|
+
{
|
|
129
|
+
"type": "function",
|
|
130
|
+
"function": {
|
|
131
|
+
"name": "get_weather",
|
|
132
|
+
"parameters": {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"properties": {"city": {"type": "string"}},
|
|
135
|
+
"required": ["city"],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
],
|
|
140
|
+
)
|
|
141
|
+
self.assertTrue(result.detected)
|
|
142
|
+
self.assertEqual(result.confidence, 0.95)
|
|
143
|
+
self.assertEqual(result.tool_call.arguments, {"city": "Tokyo"})
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
unittest.main()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from unswallow import sanitize_history, strip_reasoning_tags
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HistoryTest(unittest.TestCase):
|
|
7
|
+
def test_strip_complete_think_blocks(self):
|
|
8
|
+
text = "< thinking>\nI should get the weather for Tokyo.\n< response>\nThe weather in Tokyo is 24C."
|
|
9
|
+
self.assertEqual(strip_reasoning_tags(text), "The weather in Tokyo is 24C.")
|
|
10
|
+
|
|
11
|
+
def test_strip_xml_thinking_blocks(self):
|
|
12
|
+
text = "<thinking>\nplanning\n</thinking>\nFinal answer here."
|
|
13
|
+
self.assertEqual(strip_reasoning_tags(text), "Final answer here.")
|
|
14
|
+
|
|
15
|
+
def test_leak_keeps_text_drops_tag(self):
|
|
16
|
+
text = "Here is the summary. <mm:think>I should verify the weather data first"
|
|
17
|
+
self.assertEqual(strip_reasoning_tags(text), "Here is the summary. I should verify the weather data first")
|
|
18
|
+
|
|
19
|
+
def test_plain_text_untouched(self):
|
|
20
|
+
text = "The user asked about the weather and I answered directly."
|
|
21
|
+
self.assertEqual(strip_reasoning_tags(text), text)
|
|
22
|
+
|
|
23
|
+
def test_word_think_not_eaten(self):
|
|
24
|
+
text = "I think this is a great question."
|
|
25
|
+
self.assertEqual(strip_reasoning_tags(text), text)
|
|
26
|
+
|
|
27
|
+
def test_sanitize_history(self):
|
|
28
|
+
history = [
|
|
29
|
+
{"role": "user", "content": "What is the weather?"},
|
|
30
|
+
{
|
|
31
|
+
"role": "assistant",
|
|
32
|
+
"content": "< thinking>\nI need a tool.\n< response>\nLet me call the tool.",
|
|
33
|
+
"reasoning": "< thinking>\nI need a tool.\n< response>\n",
|
|
34
|
+
"reasoning_content": "< thinking>\nI need a tool.\n< response>\n",
|
|
35
|
+
},
|
|
36
|
+
{"role": "tool", "content": "24C"},
|
|
37
|
+
]
|
|
38
|
+
frozen = json_repr(history)
|
|
39
|
+
clean = sanitize_history(history)
|
|
40
|
+
self.assertEqual(json_repr(history), frozen)
|
|
41
|
+
self.assertEqual(len(clean), 3)
|
|
42
|
+
self.assertEqual(clean[0]["content"], "What is the weather?")
|
|
43
|
+
self.assertEqual(clean[1]["content"], "Let me call the tool.")
|
|
44
|
+
self.assertNotIn("reasoning", clean[1])
|
|
45
|
+
self.assertNotIn("reasoning_content", clean[1])
|
|
46
|
+
self.assertEqual(clean[2]["content"], "24C")
|
|
47
|
+
|
|
48
|
+
def test_options(self):
|
|
49
|
+
history = [{"role": "assistant", "content": "answer", "thinking": "< thinking>\nplan\n< response>\n"}]
|
|
50
|
+
clean = sanitize_history(history, strip_reasoning_fields=False)
|
|
51
|
+
self.assertEqual(clean[0]["thinking"], "< thinking>\nplan\n< response>\n")
|
|
52
|
+
clean2 = sanitize_history(history, strip_reasoning_tags_opt=False)
|
|
53
|
+
self.assertEqual(clean2[0]["content"], "answer")
|
|
54
|
+
clean3 = sanitize_history(history, strip_reasoning_fields=False, strip_reasoning_tags_opt=False)
|
|
55
|
+
self.assertEqual(clean3, history)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def json_repr(obj):
|
|
59
|
+
import json
|
|
60
|
+
|
|
61
|
+
return json.dumps(obj, sort_keys=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
unittest.main()
|