mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
Merge f3bbbece59 into fb8c391a88
This commit is contained in:
commit
dad515cdc9
2 changed files with 137 additions and 4 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
# src/llm_core.py
|
# src/llm_core.py
|
||||||
import httpx
|
import httpx
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -644,7 +645,7 @@ def _build_ollama_payload(
|
||||||
if options:
|
if options:
|
||||||
payload["options"] = options
|
payload["options"] = options
|
||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = tools
|
payload["tools"] = _alias_harmony_tools(tools, model)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1055,6 +1056,57 @@ def _model_disallows_reasoning_effort_with_chat_tools(model: str) -> bool:
|
||||||
return bool(re.match(r"^(?:openai/)?gpt-5(?:[.\-]\d+)?(?:[-_:].*)?$", m))
|
return bool(re.match(r"^(?:openai/)?gpt-5(?:[.\-]\d+)?(?:[-_:].*)?$", m))
|
||||||
|
|
||||||
|
|
||||||
|
# gpt-oss (harmony) ships BUILT-IN tools named `python` and `browser`, invoked
|
||||||
|
# with the raw body as the argument (`to=python` + bare source), while custom
|
||||||
|
# functions use `to=functions.NAME` + JSON. A tool we expose under a built-in's
|
||||||
|
# name therefore gets called with the built-in convention: the model emits raw
|
||||||
|
# code, the server tries to parse it as JSON, and the whole request dies
|
||||||
|
# ("error parsing tool call: raw='import sys, ...'"). In streaming mode Ollama
|
||||||
|
# does not even report it — it truncates the stream, so the turn looks like an
|
||||||
|
# empty response. `bash` collides the same way in practice.
|
||||||
|
#
|
||||||
|
# Measured on gpt-oss:20b via Ollama /v1 with a fixed agentic prompt:
|
||||||
|
# tools named python+bash ............ 2/6 succeeded (4 parse failures)
|
||||||
|
# python renamed ..................... 5/6
|
||||||
|
# python and bash renamed ............ 6/6
|
||||||
|
#
|
||||||
|
# So rename the colliding tools on the way out and map the names back on the
|
||||||
|
# way in. Confined to the transport layer: callers keep using the real names.
|
||||||
|
_HARMONY_TOOL_ALIASES = {
|
||||||
|
"python": "run_python_code",
|
||||||
|
"bash": "run_shell_command",
|
||||||
|
"browser": "web_browser_tool",
|
||||||
|
}
|
||||||
|
_HARMONY_TOOL_ALIASES_REVERSE = {v: k for k, v in _HARMONY_TOOL_ALIASES.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_harmony_model(model: str) -> bool:
|
||||||
|
"""True for gpt-oss / harmony-format models, which have built-in tool names."""
|
||||||
|
return "gpt-oss" in (model or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _alias_harmony_tools(tools: Optional[List[Dict]], model: str) -> Optional[List[Dict]]:
|
||||||
|
"""Rename tools that collide with harmony built-ins. Returns a copy."""
|
||||||
|
if not tools or not _is_harmony_model(model):
|
||||||
|
return tools
|
||||||
|
out = []
|
||||||
|
for t in tools:
|
||||||
|
fn = t.get("function") or {}
|
||||||
|
alias = _HARMONY_TOOL_ALIASES.get(fn.get("name"))
|
||||||
|
if alias:
|
||||||
|
t = copy.deepcopy(t)
|
||||||
|
t["function"]["name"] = alias
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _unalias_harmony_tool_name(name: str, model: str) -> str:
|
||||||
|
"""Map an aliased tool name in a model response back to the real name."""
|
||||||
|
if not _is_harmony_model(model):
|
||||||
|
return name
|
||||||
|
return _HARMONY_TOOL_ALIASES_REVERSE.get(name, name)
|
||||||
|
|
||||||
|
|
||||||
def _scrub_openai_chat_tool_reasoning(payload: Dict, target_url: str, model: str) -> None:
|
def _scrub_openai_chat_tool_reasoning(payload: Dict, target_url: str, model: str) -> None:
|
||||||
if not payload.get("tools"):
|
if not payload.get("tools"):
|
||||||
return
|
return
|
||||||
|
|
@ -2226,7 +2278,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
||||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||||
payload[tok_key] = max_tokens
|
payload[tok_key] = max_tokens
|
||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = tools
|
payload["tools"] = _alias_harmony_tools(tools, model)
|
||||||
elif tool_choice_none:
|
elif tool_choice_none:
|
||||||
payload["tool_choice"] = "none"
|
payload["tool_choice"] = "none"
|
||||||
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
||||||
|
|
@ -2360,7 +2412,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
||||||
if fn.get("name"):
|
if fn.get("name"):
|
||||||
_ollama_tool_calls.append({
|
_ollama_tool_calls.append({
|
||||||
"id": tc.get("id") or f"call_{len(_ollama_tool_calls)}",
|
"id": tc.get("id") or f"call_{len(_ollama_tool_calls)}",
|
||||||
"name": fn.get("name") or "",
|
"name": _unalias_harmony_tool_name(fn.get("name") or "", model),
|
||||||
"arguments": json.dumps(fn.get("arguments") or {}),
|
"arguments": json.dumps(fn.get("arguments") or {}),
|
||||||
})
|
})
|
||||||
if j.get("done"):
|
if j.get("done"):
|
||||||
|
|
@ -2740,7 +2792,10 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
||||||
if tc.get("extra_content"):
|
if tc.get("extra_content"):
|
||||||
_tc_acc[idx]["extra_content"] = tc["extra_content"]
|
_tc_acc[idx]["extra_content"] = tc["extra_content"]
|
||||||
if func.get("name"):
|
if func.get("name"):
|
||||||
_tc_acc[idx]["name"] = func["name"]
|
# Map harmony aliases back to real
|
||||||
|
# tool names before anything
|
||||||
|
# downstream sees them.
|
||||||
|
_tc_acc[idx]["name"] = _unalias_harmony_tool_name(func["name"], model)
|
||||||
if "arguments" in func:
|
if "arguments" in func:
|
||||||
# Guard against a null arguments delta: `func` can be
|
# Guard against a null arguments delta: `func` can be
|
||||||
# {"arguments": None} (JSON null), and a raw `+= None`
|
# {"arguments": None} (JSON null), and a raw `+= None`
|
||||||
|
|
|
||||||
78
tests/test_harmony_tool_aliasing.py
Normal file
78
tests/test_harmony_tool_aliasing.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""Tools whose names collide with harmony built-ins must be aliased for gpt-oss.
|
||||||
|
|
||||||
|
gpt-oss (harmony format) ships BUILT-IN tools named `python` and `browser`,
|
||||||
|
invoked with the raw body as the argument (`to=python` + bare source), while
|
||||||
|
custom functions use `to=functions.NAME` + JSON. Exposing our own tool under a
|
||||||
|
built-in's name makes the model answer with the built-in convention: it emits
|
||||||
|
raw code, the server tries to parse it as JSON, and the request dies with
|
||||||
|
"error parsing tool call: raw='import sys, ...'". Streaming is worse — Ollama
|
||||||
|
truncates the stream instead of reporting it, so the turn looks like an empty
|
||||||
|
response and the agent loop reads it as a stall.
|
||||||
|
|
||||||
|
Measured on gpt-oss:20b via Ollama /v1 with a fixed agentic prompt:
|
||||||
|
python+bash as-is 2/6, python renamed 5/6, both renamed 6/6.
|
||||||
|
|
||||||
|
The aliasing is transport-only and gpt-oss-only: every other model's tool
|
||||||
|
schemas must pass through untouched, and real tool names must come back out.
|
||||||
|
"""
|
||||||
|
from src.llm_core import (
|
||||||
|
_alias_harmony_tools,
|
||||||
|
_unalias_harmony_tool_name,
|
||||||
|
_is_harmony_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tools(*names):
|
||||||
|
return [
|
||||||
|
{"type": "function", "function": {"name": n, "parameters": {}}}
|
||||||
|
for n in names
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _names(tools):
|
||||||
|
return [t["function"]["name"] for t in tools]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpt_oss_colliding_names_are_aliased():
|
||||||
|
out = _alias_harmony_tools(_tools("python", "bash", "web_search"), "gpt-oss:20b")
|
||||||
|
assert _names(out) == ["run_python_code", "run_shell_command", "web_search"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_harmony_models_are_untouched():
|
||||||
|
tools = _tools("python", "bash", "web_search")
|
||||||
|
for model in ("qwen3-coder:30b", "gemma4:12b", "claude-opus-5", "gpt-4o", "llama-3.3"):
|
||||||
|
out = _alias_harmony_tools(tools, model)
|
||||||
|
assert _names(out) == ["python", "bash", "web_search"], model
|
||||||
|
assert out is tools, f"{model} should get the same list object, not a copy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_aliasing_does_not_mutate_the_caller_list():
|
||||||
|
tools = _tools("python")
|
||||||
|
_alias_harmony_tools(tools, "gpt-oss:20b")
|
||||||
|
assert _names(tools) == ["python"], "caller's schema list must not be mutated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_names_map_back_for_gpt_oss():
|
||||||
|
assert _unalias_harmony_tool_name("run_python_code", "gpt-oss:20b") == "python"
|
||||||
|
assert _unalias_harmony_tool_name("run_shell_command", "gpt-oss:20b") == "bash"
|
||||||
|
# Unrelated names pass through untouched.
|
||||||
|
assert _unalias_harmony_tool_name("web_search", "gpt-oss:20b") == "web_search"
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_names_untouched_for_other_models():
|
||||||
|
# A non-harmony model that genuinely has a tool called run_python_code
|
||||||
|
# must not have it rewritten to `python`.
|
||||||
|
assert _unalias_harmony_tool_name("run_python_code", "qwen3-coder:30b") == "run_python_code"
|
||||||
|
|
||||||
|
|
||||||
|
def test_harmony_detection():
|
||||||
|
assert _is_harmony_model("gpt-oss:20b") is True
|
||||||
|
assert _is_harmony_model("GPT-OSS:120B") is True
|
||||||
|
assert _is_harmony_model("qwen3-coder:30b") is False
|
||||||
|
assert _is_harmony_model("") is False
|
||||||
|
assert _is_harmony_model(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_and_none_tools_are_safe():
|
||||||
|
assert _alias_harmony_tools(None, "gpt-oss:20b") is None
|
||||||
|
assert _alias_harmony_tools([], "gpt-oss:20b") == []
|
||||||
Loading…
Add table
Reference in a new issue