Skip to content

Commit 79efb21

Browse files
committed
[UPDATE] Update
[ghstack-poisoned]
2 parents e35d01a + 4648639 commit 79efb21

7 files changed

Lines changed: 192 additions & 25 deletions

File tree

extension/llm/runner/llm_session.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
// Model-agnostic Engine/Session interfaces. Model-specific execution lives in
1010
// adapters that implement these (TextLLMSession over TextLLMRunner today;
11-
// Gemma4Session etc. later); the server and pybind layer depend only on these
12-
// interfaces, never on a concrete runner.
11+
// Gemma4Session etc. later); the serving code (HTTP control plane + C++ worker
12+
// binaries) depends only on these interfaces, never on a concrete runner.
1313

1414
#pragma once
1515

extension/llm/runner/test/test_util.cpp

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ namespace {
1818
using ::executorch::aten::ScalarType;
1919
using ::executorch::extension::make_tensor_ptr;
2020
using ::executorch::extension::llm::convert_to_bfloat16;
21+
using ::executorch::extension::llm::stop_safe_prefix_len;
2122
using ::executorch::extension::llm::utf8_complete_prefix_len;
2223

2324
class ConvertToBFloat16Test : public ::testing::Test {
@@ -86,4 +87,39 @@ TEST(Utf8CompletePrefixLenTest, HandlesAsciiAndMultiByteBoundaries) {
8687
EXPECT_EQ(utf8_complete_prefix_len("\x80"), 1u);
8788
}
8889

90+
TEST(StopSafePrefixLenTest, NoStopsEmitsEverything) {
91+
bool hit = true;
92+
EXPECT_EQ(stop_safe_prefix_len("hello world", {}, hit), 11u);
93+
EXPECT_FALSE(hit);
94+
}
95+
96+
TEST(StopSafePrefixLenTest, StopFoundReturnsEarliestOffsetAndExcludesIt) {
97+
bool hit = false;
98+
// "STOP" begins at offset 6; emit "Hello " (6 bytes), drop the stop and rest.
99+
EXPECT_EQ(stop_safe_prefix_len("Hello STOP there", {"STOP"}, hit), 6u);
100+
EXPECT_TRUE(hit);
101+
// Earliest of several wins.
102+
hit = false;
103+
EXPECT_EQ(stop_safe_prefix_len("aXbY", {"Y", "X"}, hit), 1u);
104+
EXPECT_TRUE(hit);
105+
}
106+
107+
TEST(StopSafePrefixLenTest, HoldsBackPossiblePartialStopTail) {
108+
bool hit = false;
109+
// No full stop yet, but the trailing "ST" could become "STOP": hold back
110+
// len("STOP")-1 == 3 bytes, so of "hi ST" (5 bytes) only "hi" (2) is safe.
111+
EXPECT_EQ(stop_safe_prefix_len("hi ST", {"STOP"}, hit), 2u);
112+
EXPECT_FALSE(hit);
113+
}
114+
115+
TEST(StopSafePrefixLenTest, HoldBackSnapsToUtf8Boundary) {
116+
bool hit = false;
117+
// "ab" + "€"(3 bytes). Stop "XX" => hold back 1 byte, which would land inside
118+
// the euro sign; snap down so the multi-byte char isn't split.
119+
const std::string text = "ab\xe2\x82\xac";
120+
const size_t safe = stop_safe_prefix_len(text, {"XX"}, hit);
121+
EXPECT_FALSE(hit);
122+
EXPECT_EQ(safe, 2u); // only "ab"; the € is held whole
123+
}
124+
89125
} // namespace

extension/llm/runner/util.h

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include <executorch/runtime/platform/compiler.h>
1414
#include <stdio.h>
1515
#include <time.h>
16+
#include <algorithm>
1617
#include <cctype>
1718
#include <string>
1819
#include <vector>
@@ -98,6 +99,55 @@ ET_EXPERIMENTAL size_t inline utf8_complete_prefix_len(const std::string& s) {
9899
return i;
99100
}
100101

102+
// How many leading bytes of `text` a streaming consumer may safely emit given a
103+
// set of `stops` strings, and whether a stop was hit (`stop_hit`).
104+
// * If any stop occurs, returns the byte offset of the EARLIEST occurrence and
105+
// sets stop_hit=true — text before it is safe; the stop and everything after
106+
// are dropped (the stop is excluded from output).
107+
// * Otherwise returns the length minus the longest possible partial-stop tail
108+
// (max(len(stop))-1 bytes), snapped DOWN to a UTF-8 boundary so a multi-byte
109+
// character is never split; stop_hit=false. Holding back that tail lets a
110+
// stop that straddles the next piece still be caught.
111+
// `text` is expected to be complete-UTF-8 (e.g. the assembled output of
112+
// utf8_complete_prefix_len). Empty `stops` => emit everything, no hold-back.
113+
ET_EXPERIMENTAL size_t inline stop_safe_prefix_len(
114+
const std::string& text,
115+
const std::vector<std::string>& stops,
116+
bool& stop_hit) {
117+
stop_hit = false;
118+
if (stops.empty()) {
119+
return text.size();
120+
}
121+
size_t earliest = std::string::npos;
122+
size_t max_len = 0;
123+
for (const auto& s : stops) {
124+
if (s.empty()) {
125+
continue;
126+
}
127+
max_len = std::max(max_len, s.size());
128+
const size_t p = text.find(s);
129+
if (p != std::string::npos &&
130+
(earliest == std::string::npos || p < earliest)) {
131+
earliest = p;
132+
}
133+
}
134+
if (earliest != std::string::npos) {
135+
stop_hit = true;
136+
return earliest;
137+
}
138+
const size_t hold = max_len > 0 ? max_len - 1 : 0;
139+
if (text.size() <= hold) {
140+
return 0;
141+
}
142+
size_t end = text.size() - hold;
143+
// Don't cut in the middle of a UTF-8 character: back up over continuation
144+
// bytes (10xxxxxx).
145+
while (end > 0 && (static_cast<unsigned char>(text[end]) & 0xC0) == 0x80) {
146+
--end;
147+
}
148+
return end;
149+
}
150+
101151
// ----------------------------------------------------------------------------
102152
// utilities: time
103153

extension/llm/server/python/errors.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,26 @@ def body(self) -> dict:
3030

3131

3232
class ContextLengthExceeded(APIError):
33-
def __init__(self, num_tokens: int, max_context: int):
34-
super().__init__(
35-
status=400,
36-
message=(
33+
def __init__(self, num_tokens: int, max_context: int, completion_tokens: int = 0):
34+
# completion_tokens > 0: the prompt fits but prompt + requested
35+
# max_tokens would run past the window — reject up front rather than
36+
# fail (or truncate) mid-generation.
37+
if completion_tokens > 0:
38+
message = (
39+
f"This model's maximum context length is {max_context} tokens. "
40+
f"However, you requested {num_tokens + completion_tokens} tokens "
41+
f"({num_tokens} in the messages, {completion_tokens} in the "
42+
f"completion). Please reduce the length of the messages or "
43+
f"completion."
44+
)
45+
else:
46+
message = (
3747
f"This model's maximum context length is {max_context} tokens, "
3848
f"but the request has {num_tokens} prompt tokens."
39-
),
49+
)
50+
super().__init__(
51+
status=400,
52+
message=message,
4053
err_type="invalid_request_error",
4154
code="context_length_exceeded",
4255
)

extension/llm/server/python/tests/test_qwen_tool_parser.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010

1111
from executorch.extension.llm.server.python.tool_parsers import QwenFunctionCallDetector
1212

13-
_TOOLS = {"get_weather", "add"}
13+
# name -> JSON-schema `parameters` (as the server passes it to the detector).
14+
_TOOLS = {
15+
"get_weather": {"type": "object", "properties": {"city": {"type": "string"}}},
16+
"add": {
17+
"type": "object",
18+
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
19+
},
20+
}
1421

1522

1623
def _parse(text, tools=_TOOLS):
@@ -87,3 +94,33 @@ def test_missing_tool_call_wrapper_still_parses():
8794
r = _parse(text)
8895
assert len(r.calls) == 1
8996
assert json.loads(r.calls[0].arguments) == {"city": "Paris"}
97+
98+
99+
# Schema-aware coercion: the XML format is stringly-typed, so values must be cast
100+
# to the declared schema type (the cause of several BFCL function-calling misses).
101+
def test_boolean_value_coerced_by_schema():
102+
tools = {"f": {"properties": {"flag": {"type": "boolean"}}}}
103+
# The model writes a non-JSON capitalized "True"; the schema says boolean.
104+
text = "<function=f><parameter=flag>True</parameter></function>"
105+
r = _parse(text, tools)
106+
assert json.loads(r.calls[0].arguments) == {"flag": True}
107+
108+
109+
def test_string_schema_keeps_numeric_literal_as_string():
110+
tools = {"f": {"properties": {"id": {"type": "string"}}}}
111+
# A numeric-looking value the schema declares as a string must NOT become int.
112+
text = "<function=f><parameter=id>1234</parameter></function>"
113+
r = _parse(text, tools)
114+
args = json.loads(r.calls[0].arguments)
115+
assert args == {"id": "1234"} and isinstance(args["id"], str)
116+
117+
118+
def test_untyped_param_falls_back_to_json_guess():
119+
# No declared type -> best-effort JSON guess (so loosely-typed tools still work).
120+
tools = {"f": {"properties": {}}}
121+
text = (
122+
"<function=f><parameter=n>42</parameter>"
123+
"<parameter=items>[1, 2]</parameter></function>"
124+
)
125+
r = _parse(text, tools)
126+
assert json.loads(r.calls[0].arguments) == {"n": 42, "items": [1, 2]}

extension/llm/server/python/tool_parsers/hermes.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ class HermesDetector:
4040
def __init__(self):
4141
self._next_index = 0
4242

43-
def detect_and_parse(self, text: str, tool_names: set[str]) -> ParseResult:
43+
def detect_and_parse(self, text: str, tools: dict[str, dict]) -> ParseResult:
4444
"""Return leading text + any complete tool calls. On no call or a parse
4545
failure, return the original text unchanged (kept visible to the client)."""
4646
if self.bot_token not in text:
4747
return ParseResult(normal_text=text)
4848
normal = text[: text.find(self.bot_token)].strip()
4949
try:
50-
calls = self._parse_calls(text, tool_names)
50+
calls = self._parse_calls(text, tools)
5151
except _UndefinedToolCall as e:
5252
# Degrade the whole response to visible text so the undefined call
5353
# isn't silently dropped (and its valid siblings aren't executed in
@@ -61,7 +61,7 @@ def detect_and_parse(self, text: str, tool_names: set[str]) -> ParseResult:
6161
return ParseResult(normal_text=text)
6262
return ParseResult(normal_text=normal, calls=calls)
6363

64-
def _parse_calls(self, text: str, tool_names: set[str]) -> list[ToolCallItem]:
64+
def _parse_calls(self, text: str, tools: dict[str, dict]) -> list[ToolCallItem]:
6565
calls = []
6666
for raw in _CALL_RE.findall(text):
6767
if not raw.strip():
@@ -72,15 +72,15 @@ def _parse_calls(self, text: str, tool_names: set[str]) -> list[ToolCallItem]:
7272
self._make_item(
7373
entry.get("name"),
7474
entry.get("arguments", entry.get("parameters")),
75-
tool_names,
75+
tools,
7676
)
7777
)
7878
return calls
7979

8080
def _make_item(
81-
self, name: Optional[str], arguments: Any, tool_names: set[str]
81+
self, name: Optional[str], arguments: Any, tools: dict[str, dict]
8282
) -> ToolCallItem:
83-
if not name or name not in tool_names:
83+
if not name or name not in tools:
8484
raise _UndefinedToolCall(repr(name))
8585
item = ToolCallItem(
8686
tool_index=self._next_index,

extension/llm/server/python/tool_parsers/qwen.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,35 @@ class _UndefinedToolCall(Exception):
4141
response to visible text rather than emitting a partial set (spec)."""
4242

4343

44-
def _coerce(value: str) -> Any:
45-
"""Parameter values are raw text; try JSON so numbers/bools/objects get their
46-
natural type, otherwise keep the string."""
44+
def _coerce(value: str, declared_type: Optional[str]) -> Any:
45+
"""Cast a raw XML parameter string to the type declared in the tool's JSON
46+
schema.
47+
48+
The Qwen XML format is stringly-typed (`<parameter=k>v</parameter>`), so
49+
without the schema we'd have to guess. A bare `json.loads` guess mistypes two
50+
common ways: a value the schema wants as a string but that looks numeric
51+
(`"1234"`) becomes an int, and a value the schema wants as a bool but that the
52+
model didn't write as valid JSON (`True`) stays a string. Coercing to the
53+
declared type keeps the emitted OpenAI tool_call schema-valid. Falls back to a
54+
JSON guess (then the raw string) when the type is unknown or coercion fails,
55+
so untyped/loosely-typed params keep working.
56+
"""
57+
if declared_type == "string":
58+
return value
59+
if declared_type == "boolean":
60+
low = value.strip().lower()
61+
if low in ("true", "false"):
62+
return low == "true"
63+
elif declared_type == "integer":
64+
try:
65+
return int(value.strip())
66+
except (ValueError, TypeError):
67+
pass
68+
elif declared_type == "number":
69+
try:
70+
return float(value.strip())
71+
except (ValueError, TypeError):
72+
pass
4773
try:
4874
return json.loads(value)
4975
except (ValueError, TypeError):
@@ -59,9 +85,13 @@ class QwenFunctionCallDetector:
5985
def __init__(self):
6086
self._next_index = 0
6187

62-
def detect_and_parse(self, text: str, tool_names: set[str]) -> ParseResult:
88+
def detect_and_parse(self, text: str, tools: dict[str, dict]) -> ParseResult:
6389
"""Return leading text + any complete tool calls. On no call or a parse
64-
failure, return the original text unchanged (kept visible to the client)."""
90+
failure, return the original text unchanged (kept visible to the client).
91+
92+
`tools` maps each defined tool name to its JSON-schema ``parameters``
93+
object; the schema is used to coerce stringly-typed XML values to their
94+
declared types (and the key set validates names)."""
6595
first = _FUNCTION_RE.search(text)
6696
if first is None:
6797
return ParseResult(normal_text=text)
@@ -72,7 +102,7 @@ def detect_and_parse(self, text: str, tool_names: set[str]) -> ParseResult:
72102
cut = first.start()
73103
normal = text[:cut].strip()
74104
try:
75-
calls = self._parse_calls(text, tool_names)
105+
calls = self._parse_calls(text, tools)
76106
except _UndefinedToolCall as e:
77107
logger.debug("undefined tool %s; returning raw text (no partial calls)", e)
78108
return ParseResult(normal_text=text)
@@ -83,20 +113,21 @@ def detect_and_parse(self, text: str, tool_names: set[str]) -> ParseResult:
83113
return ParseResult(normal_text=text)
84114
return ParseResult(normal_text=normal, calls=calls)
85115

86-
def _parse_calls(self, text: str, tool_names: set[str]) -> list[ToolCallItem]:
116+
def _parse_calls(self, text: str, tools: dict[str, dict]) -> list[ToolCallItem]:
87117
calls = []
88118
for name, body in _FUNCTION_RE.findall(text):
119+
props = (tools.get(name) or {}).get("properties", {})
89120
args = {
90-
key: _coerce(value.strip())
121+
key: _coerce(value.strip(), props.get(key, {}).get("type"))
91122
for key, value in _PARAMETER_RE.findall(body)
92123
}
93-
calls.append(self._make_item(name, args, tool_names))
124+
calls.append(self._make_item(name, args, tools))
94125
return calls
95126

96127
def _make_item(
97-
self, name: Optional[str], arguments: dict, tool_names: set[str]
128+
self, name: Optional[str], arguments: dict, tools: dict[str, dict]
98129
) -> ToolCallItem:
99-
if not name or name not in tool_names:
130+
if not name or name not in tools:
100131
raise _UndefinedToolCall(repr(name))
101132
item = ToolCallItem(
102133
tool_index=self._next_index,

0 commit comments

Comments
 (0)