Merge pull request #437 from 4ny3l/fix-tool-calling-401
fix(serve): lift non-EOS stop tokens in C and fix redundant tools parsing in Python (#401)
This commit is contained in:
+27
-5
@@ -374,6 +374,27 @@ def generation_options(body, limit):
|
||||
if body.get("n", 1) != 1:
|
||||
raise APIError(400, "Colibri currently supports `n=1` only.", "n", "unsupported_value")
|
||||
# `tools`/`functions` are handled by render_chat (declaration) + parse_tool_calls (output).
|
||||
# Validate tools/functions structure early so malformed input fails with a clear error.
|
||||
tools_raw = body.get("tools") or body.get("functions")
|
||||
if tools_raw is not None:
|
||||
if not isinstance(tools_raw, list):
|
||||
raise APIError(400, "`tools` must be a non-empty array.", "tools", "invalid_value")
|
||||
if not tools_raw:
|
||||
raise APIError(400, "`tools` must be a non-empty array.", "tools", "invalid_value")
|
||||
for idx, tool in enumerate(tools_raw):
|
||||
if not isinstance(tool, dict):
|
||||
raise APIError(400, f"Each tool must be an object, got {type(tool).__name__} at index {idx}.",
|
||||
f"tools.{idx}", "invalid_value")
|
||||
fn = tool.get("function", tool) if isinstance(tool, dict) else {}
|
||||
if not isinstance(fn, dict):
|
||||
raise APIError(400, f"Tool function must be an object at index {idx}.",
|
||||
f"tools.{idx}.function", "invalid_value")
|
||||
if not fn.get("name"):
|
||||
raise APIError(400, f"Each tool must have a `name` at index {idx}.",
|
||||
f"tools.{idx}.function.name", "invalid_value")
|
||||
if not isinstance(fn["name"], str):
|
||||
raise APIError(400, f"Tool `name` must be a string at index {idx}.",
|
||||
f"tools.{idx}.function.name", "invalid_value")
|
||||
choice = body.get("tool_choice")
|
||||
if choice is not None:
|
||||
if isinstance(choice, str):
|
||||
@@ -863,7 +884,7 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def generation(self, body, prompt, request_id, chat):
|
||||
def generation(self, body, prompt, request_id, chat, tools=None, tool_choice=None):
|
||||
# COLI_DEBUG tees the engine transaction to stderr: 1 = decoded output stream only,
|
||||
# 2 = both sides (rendered prompt + output). render_chat already folds prior turns and
|
||||
# tool results into `prompt`, so level 2 is the full conversation the engine saw.
|
||||
@@ -875,8 +896,8 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
sys.stderr.write(f"\n===== PROMPT [{request_id}] =====\n{prompt}\n===== OUTPUT [{request_id}] =====\n")
|
||||
sys.stderr.flush()
|
||||
maximum, temperature, top_p = generation_options(body, self.server.max_tokens)
|
||||
tools = (body.get("tools") or body.get("functions") or None) if chat else None
|
||||
if body.get("tool_choice") == "none":
|
||||
# tools and tool_choice come from chat_completion() already processed/filtered
|
||||
if chat and tool_choice == "none":
|
||||
tools = None # client forbade tools: never surface tool_calls
|
||||
cache_slot = body.get("cache_slot")
|
||||
if (cache_slot is not None and
|
||||
@@ -1078,9 +1099,10 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
if not isinstance(enable_thinking, bool):
|
||||
raise APIError(400, "`enable_thinking` must be a boolean.", "enable_thinking")
|
||||
tools = body.get("tools") or body.get("functions") or None
|
||||
tool_choice = body.get("tool_choice")
|
||||
prompt = render_chat(body.get("messages"), enable_thinking, reasoning_effort, tools,
|
||||
body.get("tool_choice"))
|
||||
self.generation(body, prompt, request_id, True)
|
||||
tool_choice)
|
||||
self.generation(body, prompt, request_id, True, tools, tool_choice)
|
||||
|
||||
def completion(self, body, request_id):
|
||||
prompt = body.get("prompt")
|
||||
|
||||
+10
@@ -116,6 +116,16 @@ static void stops_arm_tok(const Cfg *c, int tok_eos, Tok *T){
|
||||
int nsp = 0;
|
||||
if (T) for (int id = 0; id < T->n_ids && g_nstop < 64; id++)
|
||||
if (T->id_special[id] && !is_stop(id)) { g_stop[g_nstop++] = id; nsp++; }
|
||||
/* #401: in serve mode keep ONLY <|endoftext|>. Role markers <|user|>/<|observation|>
|
||||
* (config stops + tokenizer special set) are boundaries the Python server owns; as
|
||||
* hard stops they cut generation the moment the model opens a <tool_call> block,
|
||||
* because int4 argmax noise picks a stop-token ID over the correct '<' token. */
|
||||
if (getenv("SERVE") && tok_eos >= 0) {
|
||||
int kept = 0;
|
||||
for (int i = 0; i < g_nstop; i++) if (g_stop[i] == tok_eos) g_stop[kept++] = g_stop[i];
|
||||
if (kept < g_nstop) fprintf(stderr, "[stop] serve mode: filtered %d non-EOS stop tokens (tool-call safety, #401)\n", g_nstop - kept);
|
||||
g_nstop = kept; nsp = 0;
|
||||
}
|
||||
fprintf(stderr, "[stop] %d stop tokens:", g_nstop);
|
||||
for (int i = 0; i < g_nstop; i++) fprintf(stderr, " %d", g_stop[i]);
|
||||
if (nsp) fprintf(stderr, " (%d from the tokenizer's special set)", nsp);
|
||||
|
||||
Reference in New Issue
Block a user