File size: 8,166 Bytes
a484e22 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | """Thin client over the sglang OpenAI-compatible endpoint for gpt-oss-120b.
Given a query and the offered tool schemas, we ask the genuinely-served model
to emit a tool call and return the (name, arguments) it produced. This is the
*target* generation used by the acceptance metric. Generation is greedy
(temperature 0) so the target is deterministic -- the correct reference for a
speculative decoder that verifies greedily.
"""
from __future__ import annotations
import json
import re
import time
from typing import Any
import os
import requests
# The sglang server runs on the GPU node; from the GPU node itself localhost
# works, but from the head/launch node it must be addressed by host. Honor an
# env override so the harness runs from either place.
DEFAULT_URL = os.environ.get("TOOL_SERVER_URL", "http://localhost:30000/v1")
# gpt-oss harmony tool-call markup, e.g.:
# ...to=functions.triangle_properties.get <|constrain|>json<|message|>{...}<|call|>
_HARMONY_NAME = re.compile(r"to=functions\.([A-Za-z0-9_.\-]+)")
_HARMONY_ARGS = re.compile(r"<\|message\|>(.*?)<\|call\|>", re.DOTALL)
def parse_harmony_content(content: str) -> dict[str, Any] | None:
"""Fallback: extract the first tool call from raw harmony content.
sglang's HarmonyParser occasionally leaves the call in `content` instead of
populating `tool_calls`; this recovers the genuine call the model emitted.
"""
if not content or "to=functions." not in content:
return None
nm = _HARMONY_NAME.search(content)
if not nm:
return None
am = _HARMONY_ARGS.search(content, nm.end())
raw = am.group(1).strip() if am else "{}"
try:
args = json.loads(raw) if raw else {}
except json.JSONDecodeError:
args = {"__raw__": raw}
return {"name": nm.group(1), "arguments": args}
# Nemotron-H / Llama-style XML tool-call markup, emitted in `content` when the
# server-side parser (hermes) does not recognize it, e.g.:
# <tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>...
_XML_FUNC = re.compile(r"<function=([A-Za-z0-9_.\-]+)\s*>")
_XML_PARAM = re.compile(r"<parameter=([A-Za-z0-9_.\-]+)\s*>(.*?)</parameter>",
re.DOTALL)
def parse_xml_content(content: str) -> dict[str, Any] | None:
"""Fallback: extract an XML-style ``<function=..><parameter=..>`` tool call.
Used for models (e.g. Nemotron-3-Super) whose native tool-call format the
served parser leaves in ``content``. Values are kept as the model's literal
strings; since draft and target pass through the same parser, only their
mutual agreement matters for the acceptance metric.
"""
if not content or "<function=" not in content:
return None
fm = _XML_FUNC.search(content)
if not fm:
return None
# scope parameters to this function block if a closing tag exists
end = content.find("</function>", fm.end())
block = content[fm.end():end if end != -1 else None]
args: dict[str, Any] = {}
for pm in _XML_PARAM.finditer(block):
val = pm.group(2).strip()
# coerce obvious scalars so canonicalization matches JSON tool_calls
low = val.lower()
if low in ("true", "false"):
args[pm.group(1)] = (low == "true")
else:
try:
args[pm.group(1)] = int(val)
except ValueError:
try:
args[pm.group(1)] = float(val)
except ValueError:
args[pm.group(1)] = val
return {"name": fm.group(1), "arguments": args}
# BFCL uses Python-style type names; JSON Schema needs these mappings.
_TYPE_MAP = {"dict": "object", "float": "number", "integer": "integer",
"tuple": "array", "list": "array", "string": "string",
"boolean": "boolean", "bool": "boolean", "int": "integer",
"number": "number", "array": "array", "object": "object"}
def _sanitize_schema(node: Any) -> Any:
"""Recursively convert BFCL Python types into valid JSON Schema."""
if isinstance(node, dict):
out = {}
for k, v in node.items():
if k == "type" and isinstance(v, str):
if v == "any":
continue # unconstrained -> omit type
out[k] = _TYPE_MAP.get(v, v)
else:
out[k] = _sanitize_schema(v)
# a "tuple"/"array" with no item schema still needs items for strict
# validators; leave as-is otherwise.
return out
if isinstance(node, list):
return [_sanitize_schema(x) for x in node]
return node
def to_openai_tools(functions: list[dict[str, Any]]) -> list[dict]:
"""Convert BFCL function schemas to OpenAI tool schema."""
tools = []
for f in functions:
params = f.get("parameters", {}) or {"type": "object", "properties": {}}
params = _sanitize_schema(dict(params))
if params.get("type") in (None, "dict"):
params["type"] = "object"
tools.append({
"type": "function",
"function": {
"name": f["name"],
"description": f.get("description", ""),
"parameters": params,
},
})
return tools
class ToolClient:
def __init__(self, url: str = DEFAULT_URL, model: str = "gpt-oss-120b",
timeout: float = 120.0):
self.url = url.rstrip("/")
self.model = model
self.timeout = timeout
def ping(self) -> bool:
try:
r = requests.get(f"{self.url}/models", timeout=5)
return r.status_code == 200
except Exception:
return False
def generate_call(self, query: str, functions: list[dict[str, Any]],
retries: int = 3) -> dict[str, Any] | None:
"""Return {'name':..., 'arguments':{...}} for the model's tool call.
Returns None if the model declined to call a tool or on hard failure.
"""
tools = to_openai_tools(functions)
payload = {
"model": self.model,
"messages": [
{"role": "system", "content":
"You are a function-calling agent. Call exactly one of the "
"provided tools to satisfy the user's request."},
{"role": "user", "content": query},
],
"tools": tools,
# gpt-oss harmony parser rejects tool_choice="required"
# (structure_info conflict); it natively emits tool calls with auto.
"tool_choice": "auto",
"temperature": 0.0,
"max_tokens": 512,
}
last_err = None
for attempt in range(retries):
try:
r = requests.post(f"{self.url}/chat/completions", json=payload,
timeout=self.timeout)
if r.status_code != 200:
last_err = f"http {r.status_code}: {r.text[:200]}"
time.sleep(1.5 * (attempt + 1))
continue
msg = r.json()["choices"][0]["message"]
tcs = msg.get("tool_calls") or []
if not tcs:
# parser left the call in content -> recover it ourselves.
# Try gpt-oss harmony markup first, then Nemotron/Llama XML.
content = msg.get("content") or ""
return (parse_harmony_content(content)
or parse_xml_content(content))
fn = tcs[0]["function"]
args = fn.get("arguments", "{}")
if isinstance(args, str):
try:
args = json.loads(args) if args.strip() else {}
except json.JSONDecodeError:
args = {"__raw__": args}
return {"name": fn["name"], "arguments": args}
except Exception as e: # noqa: BLE001
last_err = str(e)
time.sleep(1.5 * (attempt + 1))
raise RuntimeError(f"generate_call failed after {retries}: {last_err}")
|