Page MenuHomePhabricator

Explore LLM tool calling support in our vLLM-based inference services
Closed, ResolvedPublic

Description

Summary

This task is about LLM tool calling (function calling) at the model-server level: the ability of a Lift Wing inference service to accept OpenAI-style tools definitions in the request and return parsed tool_calls in the response, as supported by vLLM. This is server-side functionality of the LLM endpoint itself, not to be confused with enabling tools in a client application or UI (e.g. Open WebUI), which merely consumes this capability. A test showed tool calling already works on the llm-qwen3-14b endpoint. in T433977: Support OpenAI-compatible tool calling in the qwen36 model-server. We want to understand how it is enabled in vLLM, whether it works reliably across the LLMs we host, and whether we can offer it as a supported feature.

Technical notes

In vLLM, tool call parsing happens in the OpenAI-compatible serving layer (not the engine) and is enabled with --enable-auto-tool-choice plus a model-family-specific --tool-call-parser (e.g. hermes for Qwen models, llama3_json for Llama 3.x).

example request

curl 'https://api.wikimedia.org/service/lw/inference/v1/models/llm-qwen3-14b/openai/v1/chat/completions' \
  -H 'Content-Type: application/json' \
  -H 'User-Agent: my-tool/0.1 (you@wikimedia.org)' \
  -d '{
    "model": "llm-qwen3-14b",
    "messages": [{"role": "user", "content": "A user question the tool can help answer"}],
    "tools": [{
      "type": "function",
      "function": {
        "name": "my_tool_name",
        "description": "Description of what the tool does",
        "parameters": {
          "type": "object",
          "properties": {"my_param": {"type": "string", "description": "Description of the parameter"}},
          "required": ["my_param"]
        }
      }
    }],
    "tool_choice": "auto",
    "temperature": 0
  }'

The response contains a parsed tool call instead of text content:

"message": {"role": "assistant", "content": null,
  "tool_calls": [{"id": "call_xxxxxxxx", "type": "function",
    "function": {"name": "my_tool_name", "arguments": "{\"my_param\": \"value chosen by the model\"}"}}]},
"finish_reason": "tool_calls"

Links:

Acceptance criteria

  • We understand how tool calling is enabled in vLLM (e.g. tool call parser configuration per model family) and which Lift Wing LLMs support it.
  • We deploy and test a specific tool on the server side and to the documentation how such requests can be made. (currently test tools are hosted, we'll run load tests on tools that will receive real traffic)
  • We have a recommendation on whether to officially support and document tool calling on Lift Wing. (see T434274#12256487)

Event Timeline

Change #1326993 had a related patch set uploaded (by Kevin Bazira; author: Kevin Bazira):

[machinelearning/liftwing/inference-services@main] qwen36: adopt vLLM's tool parsers (non-streaming)

https://gerrit.wikimedia.org/r/1326993

kevinbazira reopened this task as Open.
kevinbazira claimed this task.

Change #1326993 merged by jenkins-bot:

[machinelearning/liftwing/inference-services@main] qwen36: adopt vLLM's tool parsers (non-streaming)

https://gerrit.wikimedia.org/r/1326993

Change #1327108 had a related patch set uploaded (by Kevin Bazira; author: Kevin Bazira):

[machinelearning/liftwing/inference-services@main] qwen36: stream tool calls via vLLM's parser

https://gerrit.wikimedia.org/r/1327108

Change #1327108 merged by jenkins-bot:

[machinelearning/liftwing/inference-services@main] qwen36: stream tool calls via vLLM's parser

https://gerrit.wikimedia.org/r/1327108

Change #1327508 had a related patch set uploaded (by Kevin Bazira; author: Kevin Bazira):

[machinelearning/liftwing/inference-services@main] qwen36: build the tool parser per request

https://gerrit.wikimedia.org/r/1327508

Change #1327508 merged by jenkins-bot:

[machinelearning/liftwing/inference-services@main] qwen36: build the tool parser per request

https://gerrit.wikimedia.org/r/1327508

Change #1327543 had a related patch set uploaded (by Kevin Bazira; author: Kevin Bazira):

[machinelearning/liftwing/inference-services@main] qwen36: honor tool_choice on chat completions

https://gerrit.wikimedia.org/r/1327543

Change #1327543 merged by jenkins-bot:

[machinelearning/liftwing/inference-services@main] qwen36: honor tool_choice on chat completions

https://gerrit.wikimedia.org/r/1327543

Change #1329158 had a related patch set uploaded (by Kevin Bazira; author: Kevin Bazira):

[operations/deployment-charts@master] ml-services: deploy qwen36-27b to use qwen3_coder parser for tool calling

https://gerrit.wikimedia.org/r/1329158

Change #1329158 merged by jenkins-bot:

[operations/deployment-charts@master] ml-services: deploy qwen36-27b to use qwen3_coder parser for tool calling

https://gerrit.wikimedia.org/r/1329158

Change #1329272 had a related patch set uploaded (by Kevin Bazira; author: Kevin Bazira):

[machinelearning/liftwing/inference-services@main] qwen36: render tool-call history for templates that expect mapping arguments

https://gerrit.wikimedia.org/r/1329272

Change #1329272 merged by jenkins-bot:

[machinelearning/liftwing/inference-services@main] qwen36: render tool-call history for templates that expect mapping arguments

https://gerrit.wikimedia.org/r/1329272

Change #1329417 had a related patch set uploaded (by Kevin Bazira; author: Kevin Bazira):

[operations/deployment-charts@master] ml-services: deploy qwen36 image with tool-call history normalization

https://gerrit.wikimedia.org/r/1329417

Change #1329417 merged by jenkins-bot:

[operations/deployment-charts@master] ml-services: deploy qwen36 image with tool-call history normalization

https://gerrit.wikimedia.org/r/1329417

We enabled tool calling in the Qwen isvc using vLLM. This works in two parts: the model writes a tool call as text, in its own format. Then a parser for that model family changes the text into structured tool_calls. The vLLM flags --enable-auto-tool-choice and --tool-call-parser (shown in this task's description) configure vLLM's own server. We do not run that server. If we give those flags to our vLLM engine, it fails at startup with a TypeError. Our model-server uses vLLM's parser classes directly. The TOOL_CALL_PARSER setting selects the parser for each service, and TOOL_CALLING_ENABLED turns the feature on or off.

The parser must match the model. Qwen3 (14B) writes JSON in tags: the parser is hermes. Qwen3.6 (27B) writes an XML format: the parser is qwen3_coder. Each parser cannot read the other format.

Below is the support matrix, verified in production:

ServiceParserFirst turnStreamingRound trip
llm-qwen3-14bhermesworksworksworks
llm-qwen36-27bqwen3_coderworksworksworks
NOTE: gpt-oss-safeguard-20b is out of scope. It is a different model family on a different server. As discussed in the meeting, we'll look into other isvcs once we've validated tool calling on the qwen isvcs.

Here is an example of tool calling in the llm-qwen36-27b isvc:

$ TOOLS='[{"type":"function","function":{"name":"get_weather","description":"Get the current weather for a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]'

# Checks 1 and 2:
# 1. First turn: the model gets the question and the tool list, and returns a tool call.
# 2. Streaming: the same reply, sent piece by piece while it is generated.
$ curl -sN "https://inference.svc.eqiad.wmnet:30443/openai/v1/chat/completions" \
-X POST -H "Host: llm-qwen36-27b.llm.wikimedia.org" \
-H "Content-Type: application/json" \
-d '{"model":"llm-qwen36-27b","messages":[{"role":"user","content":"What is the weather in Kampala? Use the tool."}],"tools":'"$TOOLS"',"max_tokens":300,"stream":true}' > /tmp/turn1.txt
$ 
$ 
$ python3 - <<'PY' > /tmp/call.json
import json
tc_id, name, args, n, fin = None, None, [], 0, None
content = []
for line in open("/tmp/turn1.txt"):
    line = line.strip()
    if not line.startswith("data: ") or line == "data: [DONE]": continue
    d = json.loads(line[6:]); ch = d["choices"][0]; delta = ch.get("delta") or {}
    if delta.get("content"): content.append(delta["content"])
    for tc in delta.get("tool_calls") or []:
        n += 1
        fn = tc.get("function") or {}
        if tc.get("id"): tc_id = tc["id"]
        if fn.get("name"): name = fn["name"]
        if fn.get("arguments"): args.append(fn["arguments"])
    if ch.get("finish_reason"): fin = ch["finish_reason"]
c = "".join(content)
print(json.dumps({"id": tc_id, "name": name, "arguments": "".join(args)}))
import sys
print(f"CHECK 1+2: finish={fin} | tc_delta_chunks={n} | tag_leak={'<function=' in c or '<tool_call>' in c}", file=sys.stderr)
PY

CHECK 1+2: finish=tool_calls | tc_delta_chunks=4 | tag_leak=False
$ 
$ 
$ ID=$(python3 -c 'import json;print(json.load(open("/tmp/call.json"))["id"])')
$ ARGS=$(python3 -c 'import json;print(json.load(open("/tmp/call.json"))["arguments"])')

# the client's job: execute the tool (simulated)
$ RESULT='{"temp_c": 24, "condition": "partly cloudy"}'
 
# Check 3:
# 3. Round trip: the client sends the tool result back as `content`, and the model writes the final answer.
$ curl -s "https://inference.svc.eqiad.wmnet:30443/openai/v1/chat/completions" -X POST -H "Host: llm-qwen36-27b.llm.wikimedia.org" -H "Content-Type: application/json" \
  -d '{"model":"llm-qwen36-27b","messages":[
    {"role":"user","content":"What is the weather in Kampala? Use the tool."},
    {"role":"assistant","content":null,"tool_calls":[{"id":"'"$ID"'","type":"function","function":{"name":"get_weather","arguments":'"$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$ARGS")"'}}]},
    {"role":"tool","tool_call_id":"'"$ID"'","content":"{\"temp_c\": 24, \"condition\": \"partly cloudy\"}"}],
    "tools":'"$TOOLS"',"max_tokens":300}' \
  | python3 -c 'import json,sys;r=json.load(sys.stdin);c=r["choices"][0];print("CHECK 3: finish=",c["finish_reason"],"| prompt_tokens:",r["usage"]["prompt_tokens"]);print("answer:",c["message"]["content"])'

CHECK 3: finish= stop | prompt_tokens: 344
answer: The current weather in Kampala is partly cloudy with a temperature of 24°C.

Tools should not be deployed within a vLLM model-server by design.

The model-server has one job: run the model and return its intent as tool_calls. It accepts the tool definition (name, description, parameters) and never sees or runs the tool's code as shown in T434274#12255976

The tool lives in the client, which runs the function and sends the result back. This back-and-forth loop follows the OpenAI function-calling flow:

client sends definitions -> server returns tool_calls -> client runs the function -> client sends the result -> server returns the final answer.

We showed this in production: in T433977#12194179, LiftWing Studio was the client and hosted the tool, while the Qwen inference service did inference.

Hosting a tool in the model-server is an anti-pattern, runs untrusted code on a shared server, and ties the server to the client's data. It's recommended to keep the tool in the client or separate tool-server.

@isarantopoulos, the second criterion of this task says:

We deploy and test a specific tool on the server side and to the documentation how such requests can be made.

Following T434274#12256418, would you like us to host a separate tool-server on LiftWing? Wouldn't this tool-server duplicate LiftWing Studio tool calling functionality?

Since Liftwing studio is not a production offering this means that a each of our clients would need to build/define a tool. Apart from running a client's own tools we want to enable some capabilities by offering a since entrypoint which would be a request to Liftwing.
I propose that we host a tool-server where we can keep a limited toolbelt of universal tools.

I suggest we further discuss this in our technical meeting and create a plan before jumping in an implementation.

Following T434274#12256487, I ran a PoC with a client, tool-server, and isvc. Below is how the 3 components interact:

option 1: client-owned loop - Screenshot from 2026-09-02 15-15-33.png (1,286×659 px, 106 KB)

Then shared with the team a presentation about tool calling with a tool-server on LiftWing.

@isarantopoulos asked a follow up question: "Can the isvc access the tool-server directly?"

I looked into this and the isvc cannot access the tool-server directly because T434274#12256418. However, we can use an orchestrator that would run the loop as a service so that the client makes one request and gets back the answer, with all the tool calling work happening on LiftWing.

I ran another PoC with a client, orchestrator, tool-server, and isvc. Below is how the 4 components interact:

option 2: orchestrator-owned loop - Screenshot from 2026-09-02 15-15-13.png (1,286×659 px, 125 KB)

@isarantopoulos as discussed in yesterday's ML technical meeting, here is the tool-server implementation plan that I propose: T436892

Please confirm whether we should proceed with the implementation.

@isarantopoulos as discussed in yesterday's ML technical meeting, here is the tool-server implementation plan that I propose: T436892

Please confirm whether we should proceed with the implementation.

We discussed the implementation plan in a slack thread and we'll proceed with the implementation.

The tool-server is now up and running in LiftWing production as shown in: T436892#12307974

Clients can now get answers from LiftWing LLMs that are grounded in the tool results: T436892#12312616

Here is documentation on:

@isarantopoulos, following our discussion in today's meeting, I've added tool calling documentation to our wikitech LLM docs:
https://wikitech.wikimedia.org/wiki/Machine_Learning/LiftWing/Large_Language_Models#Tool_calling

kevinbazira updated the task description. (Show Details)