Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64b6a3d826 | ||
|
|
da5ded48ab | ||
|
|
23217bdb26 |
@@ -10,12 +10,20 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
|
# Idempotent: a re-fired tag finds its release already present and
|
||||||
|
# says so instead of failing on the 409.
|
||||||
run: |
|
run: |
|
||||||
|
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||||
|
auth='Authorization: token ${{ secrets.GITHUB_TOKEN }}'
|
||||||
|
if curl -sf -H "$auth" "$api/tags/${{ github.ref_name }}" > /dev/null; then
|
||||||
|
echo "release for ${{ github.ref_name }} already exists — nothing to do"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
curl -sf -X POST \
|
curl -sf -X POST \
|
||||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
-H "$auth" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
"$api"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.6.0] - 2026-09-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `BACKEND_SLOT_PINNING` (default off): pins each pipeline phase to a
|
||||||
|
llama-server slot (steward=0, orchestrator=1, synthesizer=2) so a
|
||||||
|
phase's stable prompt prefix stays in that slot's KV cache. No-op on
|
||||||
|
Claude; ignored by Ollama.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Tool-calling no longer loops on llama-server backends: the health
|
||||||
|
check detects the server flavor, and `tool_choice: "required"` (an
|
||||||
|
advisory nudge on Ollama, an every-request mandate on llama-server)
|
||||||
|
is sent only to Ollama. On llama-server the model calls tools
|
||||||
|
unforced via its chat template.
|
||||||
|
|
||||||
## [2.5.0] - 2026-09-11
|
## [2.5.0] - 2026-09-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tatlock"
|
name = "tatlock"
|
||||||
version = "2.5.0"
|
version = "2.6.0"
|
||||||
description = "OpenAI-compatible API with Ollama backend"
|
description = "OpenAI-compatible API with Ollama backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -162,15 +162,20 @@ class StewardAgent:
|
|||||||
OpenAI-compatible server (Ollama, llama-server) can sit behind
|
OpenAI-compatible server (Ollama, llama-server) can sit behind
|
||||||
OLLAMA_HOST without this method knowing which.
|
OLLAMA_HOST without this method knowing which.
|
||||||
"""
|
"""
|
||||||
|
payload: dict = {
|
||||||
|
"model": self.ollama_model,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"temperature": 0.3, # Lower = more consistent
|
||||||
|
"top_p": 0.9,
|
||||||
|
}
|
||||||
|
if config.BACKEND_SLOT_PINNING:
|
||||||
|
# The steward owns engine slot 0 (see BACKEND_SLOT_PINNING)
|
||||||
|
payload["id_slot"] = 0
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{self.ollama_host}/v1/chat/completions",
|
f"{self.ollama_host}/v1/chat/completions",
|
||||||
json={
|
json=payload,
|
||||||
"model": self.ollama_model,
|
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
|
||||||
"temperature": 0.3, # Lower = more consistent
|
|
||||||
"top_p": 0.9,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -536,13 +536,13 @@ class TatlockAgent(AgentInterface):
|
|||||||
|
|
||||||
# Run with scoped tools and tracker
|
# Run with scoped tools and tracker
|
||||||
# Force tool_choice to make LLM actually call tools
|
# Force tool_choice to make LLM actually call tools
|
||||||
from src.anthropic.model_selector import get_tool_choice_settings
|
from src.anthropic.model_selector import get_tool_choice_settings, with_slot_pinning
|
||||||
|
|
||||||
result = await scoped_agent.run(
|
result = await scoped_agent.run(
|
||||||
enriched_message,
|
enriched_message,
|
||||||
message_history=pydantic_history if pydantic_history else None,
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
deps=tool_tracker,
|
deps=tool_tracker,
|
||||||
model_settings=get_tool_choice_settings(),
|
model_settings=with_slot_pinning(get_tool_choice_settings(), slot=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -725,13 +725,13 @@ class TatlockAgent(AgentInterface):
|
|||||||
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
|
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
|
||||||
|
|
||||||
# Run with scoped tools and tracker
|
# Run with scoped tools and tracker
|
||||||
from src.anthropic.model_selector import get_tool_choice_settings
|
from src.anthropic.model_selector import get_tool_choice_settings, with_slot_pinning
|
||||||
|
|
||||||
result = await scoped_agent.run(
|
result = await scoped_agent.run(
|
||||||
enriched_message,
|
enriched_message,
|
||||||
message_history=pydantic_history if pydantic_history else None,
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
deps=tool_tracker,
|
deps=tool_tracker,
|
||||||
model_settings=get_tool_choice_settings(),
|
model_settings=with_slot_pinning(get_tool_choice_settings(), slot=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract tool calls and results from the agent's messages
|
# Extract tool calls and results from the agent's messages
|
||||||
@@ -888,9 +888,12 @@ class TatlockAgent(AgentInterface):
|
|||||||
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
|
pydantic_history.append(ModelResponse(parts=[TextPart(content=content)]))
|
||||||
|
|
||||||
# Run synthesis
|
# Run synthesis
|
||||||
|
from src.anthropic.model_selector import with_slot_pinning
|
||||||
|
|
||||||
result = await synthesis_agent.run(
|
result = await synthesis_agent.run(
|
||||||
synthesis_prompt,
|
synthesis_prompt,
|
||||||
message_history=pydantic_history if pydantic_history else None,
|
message_history=pydantic_history if pydantic_history else None,
|
||||||
|
model_settings=with_slot_pinning(None, slot=2),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ logger = get_logger(__name__)
|
|||||||
# Cached health check results (set once at startup)
|
# Cached health check results (set once at startup)
|
||||||
_claude_available: bool | None = None
|
_claude_available: bool | None = None
|
||||||
_ollama_available: bool | None = None
|
_ollama_available: bool | None = None
|
||||||
|
# Which server answers behind OLLAMA_HOST: "llama-server" or "ollama".
|
||||||
|
# They disagree on tool_choice semantics (see get_tool_choice_settings),
|
||||||
|
# so the health check probes /props — served by llama-server only — and
|
||||||
|
# caches the answer here.
|
||||||
|
_local_flavor: str | None = None
|
||||||
|
|
||||||
|
|
||||||
async def check_ollama_health() -> bool:
|
async def check_ollama_health() -> bool:
|
||||||
@@ -40,7 +45,7 @@ async def check_ollama_health() -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
True if Ollama is reachable and OLLAMA_DEFAULT_MODEL is pulled.
|
True if Ollama is reachable and OLLAMA_DEFAULT_MODEL is pulled.
|
||||||
"""
|
"""
|
||||||
global _ollama_available
|
global _ollama_available, _local_flavor
|
||||||
|
|
||||||
host = str(config.OLLAMA_HOST).rstrip("/")
|
host = str(config.OLLAMA_HOST).rstrip("/")
|
||||||
model = config.OLLAMA_DEFAULT_MODEL
|
model = config.OLLAMA_DEFAULT_MODEL
|
||||||
@@ -53,12 +58,20 @@ async def check_ollama_health() -> bool:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
names = [m.get("id", "") for m in response.json().get("data", [])]
|
names = [m.get("id", "") for m in response.json().get("data", [])]
|
||||||
|
|
||||||
|
# /props is llama-server's own surface; Ollama 404s it.
|
||||||
|
try:
|
||||||
|
props = await client.get(f"{host}/props")
|
||||||
|
_local_flavor = "llama-server" if props.status_code == 200 else "ollama"
|
||||||
|
except httpx.HTTPError:
|
||||||
|
_local_flavor = "ollama"
|
||||||
|
|
||||||
if model in names or f"{model}:latest" in names:
|
if model in names or f"{model}:latest" in names:
|
||||||
_ollama_available = True
|
_ollama_available = True
|
||||||
logger.info(
|
logger.info(
|
||||||
"ollama_health_check_passed",
|
"ollama_health_check_passed",
|
||||||
host=host,
|
host=host,
|
||||||
model=model,
|
model=model,
|
||||||
|
flavor=_local_flavor,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -244,19 +257,52 @@ def get_model(prefer_cloud: bool | None = None) -> AnthropicModel | OpenAIChatMo
|
|||||||
|
|
||||||
def get_tool_choice_settings() -> ModelSettings:
|
def get_tool_choice_settings() -> ModelSettings:
|
||||||
"""
|
"""
|
||||||
Get model_settings for forcing tool calls on the first request.
|
Get model_settings for tool calling on the orchestration phase.
|
||||||
|
|
||||||
For Claude: PydanticAI handles tool_choice natively, so no extra_body needed.
|
Claude: PydanticAI handles tool_choice natively — no extra_body.
|
||||||
For Ollama: Pass tool_choice="required" via extra_body to force tool calling.
|
|
||||||
|
Ollama: tool_choice="required" via extra_body. Advisory there (Ollama
|
||||||
|
ignores it), but it nudges gemma4 to actually call tools, which the
|
||||||
|
persona-suppression gotcha made necessary.
|
||||||
|
|
||||||
|
llama-server: NO tool_choice. It enforces "required" on every request
|
||||||
|
in the run, so after a tool returns, the next generation is again
|
||||||
|
forced to call a tool — an unbreakable tool loop (~80 s turns,
|
||||||
|
observed at cutover). Its --jinja template renders tool definitions
|
||||||
|
the way gemma4 was trained, and the model calls tools reliably
|
||||||
|
unforced (probed 3/3).
|
||||||
"""
|
"""
|
||||||
from pydantic_ai.settings import ModelSettings
|
from pydantic_ai.settings import ModelSettings
|
||||||
|
|
||||||
if resolve_backend() == "claude":
|
if resolve_backend() == "claude":
|
||||||
# PydanticAI's Anthropic model handles tool_choice internally
|
# PydanticAI's Anthropic model handles tool_choice internally
|
||||||
return ModelSettings()
|
return ModelSettings()
|
||||||
else:
|
if _local_flavor == "llama-server":
|
||||||
# Ollama needs explicit tool_choice via extra_body
|
return ModelSettings()
|
||||||
return ModelSettings(extra_body={"tool_choice": "required"})
|
# Ollama needs explicit tool_choice via extra_body
|
||||||
|
return ModelSettings(extra_body={"tool_choice": "required"})
|
||||||
|
|
||||||
|
|
||||||
|
def with_slot_pinning(settings: ModelSettings | None, slot: int) -> ModelSettings | None:
|
||||||
|
"""
|
||||||
|
Merge llama-server slot pinning into model settings when enabled.
|
||||||
|
|
||||||
|
Each pipeline phase owns one engine slot (steward=0, orchestrator=1,
|
||||||
|
synthesizer=2), so the phase's stable prompt prefix stays in that
|
||||||
|
slot's KV cache and a turn re-prefills only its new tokens. Off by
|
||||||
|
default (BACKEND_SLOT_PINNING); a no-op on the Claude backend, and
|
||||||
|
Ollama ignores the field, so enabling it is safe on any backend.
|
||||||
|
"""
|
||||||
|
if not config.BACKEND_SLOT_PINNING or resolve_backend() == "claude":
|
||||||
|
return settings
|
||||||
|
|
||||||
|
from pydantic_ai.settings import ModelSettings
|
||||||
|
|
||||||
|
merged = dict(settings or {})
|
||||||
|
extra_body = dict(merged.get("extra_body") or {})
|
||||||
|
extra_body["id_slot"] = slot
|
||||||
|
merged["extra_body"] = extra_body
|
||||||
|
return ModelSettings(**merged)
|
||||||
|
|
||||||
|
|
||||||
def get_sampling_settings(temperature: float) -> ModelSettings:
|
def get_sampling_settings(temperature: float) -> ModelSettings:
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ class Config(BaseSettings):
|
|||||||
default=None,
|
default=None,
|
||||||
description="OpenAI-compatible embeddings host; falls back to OLLAMA_HOST so gen and embed can live on different servers",
|
description="OpenAI-compatible embeddings host; falls back to OLLAMA_HOST so gen and embed can live on different servers",
|
||||||
)
|
)
|
||||||
|
BACKEND_SLOT_PINNING: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Pin each pipeline phase to a llama-server slot (steward=0, orchestrator=1, synthesizer=2) so a phase's stable prompt prefix stays in that slot's KV cache; no-op on Claude, ignored by Ollama",
|
||||||
|
)
|
||||||
|
|
||||||
# Redis Memory Database
|
# Redis Memory Database
|
||||||
REDIS_MEMORY_DB: int = Field(default=1, description="Redis database number for memory cache")
|
REDIS_MEMORY_DB: int = Field(default=1, description="Redis database number for memory cache")
|
||||||
|
|||||||
@@ -91,3 +91,49 @@ class TestGetModelInfo:
|
|||||||
info = model_selector.get_model_info()
|
info = model_selector.get_model_info()
|
||||||
assert info["backend"] == "claude"
|
assert info["backend"] == "claude"
|
||||||
assert info["model"] == config.ANTHROPIC_MODEL
|
assert info["model"] == config.ANTHROPIC_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
class TestWithSlotPinning:
|
||||||
|
def test_disabled_returns_settings_unchanged(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", False)
|
||||||
|
base = model_selector.get_tool_choice_settings()
|
||||||
|
assert model_selector.with_slot_pinning(base, slot=1) is base
|
||||||
|
assert model_selector.with_slot_pinning(None, slot=2) is None
|
||||||
|
|
||||||
|
def test_enabled_merges_slot_with_existing_extra_body(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||||
|
settings = model_selector.with_slot_pinning(
|
||||||
|
model_selector.get_tool_choice_settings(), slot=1
|
||||||
|
)
|
||||||
|
extra_body = settings["extra_body"]
|
||||||
|
assert extra_body["id_slot"] == 1
|
||||||
|
# tool_choice from the base settings survives the merge
|
||||||
|
assert extra_body["tool_choice"] == "required"
|
||||||
|
|
||||||
|
def test_enabled_pins_bare_settings(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||||
|
settings = model_selector.with_slot_pinning(None, slot=2)
|
||||||
|
assert settings["extra_body"] == {"id_slot": 2}
|
||||||
|
|
||||||
|
def test_claude_backend_never_pinned(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||||
|
monkeypatch.setattr(config, "PREFER_CLOUD_BACKEND", True)
|
||||||
|
base = model_selector.get_tool_choice_settings()
|
||||||
|
assert model_selector.with_slot_pinning(base, slot=1) is base
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocalFlavorToolChoice:
|
||||||
|
def test_llama_server_flavor_sends_no_tool_choice(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_selector, "_local_flavor", "llama-server")
|
||||||
|
settings = model_selector.get_tool_choice_settings()
|
||||||
|
assert not settings.get("extra_body")
|
||||||
|
|
||||||
|
def test_ollama_flavor_keeps_advisory_required(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_selector, "_local_flavor", "ollama")
|
||||||
|
settings = model_selector.get_tool_choice_settings()
|
||||||
|
assert settings["extra_body"] == {"tool_choice": "required"}
|
||||||
|
|
||||||
|
def test_unknown_flavor_defaults_to_ollama_semantics(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_selector, "_local_flavor", None)
|
||||||
|
settings = model_selector.get_tool_choice_settings()
|
||||||
|
assert settings["extra_body"] == {"tool_choice": "required"}
|
||||||
|
|||||||
@@ -114,16 +114,18 @@ class TestLocalBackendContract:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def test_openai_compat_tool_calling(self):
|
async def test_openai_compat_tool_calling(self):
|
||||||
# Mirrors the request PydanticAI's OpenAIChatModel sends for the
|
# Mirrors the orchestration-phase request on llama-server: tools
|
||||||
# orchestration phase, including the extra_body tool_choice.
|
# attached, NO tool_choice. The backend must call the tool unforced
|
||||||
|
# — "required" is deliberately absent because llama-server enforces
|
||||||
|
# it on every request in a run, which turns the tool loop
|
||||||
|
# unbreakable (observed at cutover: ~80 s turns).
|
||||||
response = await _post_or_skip(
|
response = await _post_or_skip(
|
||||||
f"{OLLAMA}/v1/chat/completions",
|
f"{OLLAMA}/v1/chat/completions",
|
||||||
"ollama",
|
"local-backend",
|
||||||
{
|
{
|
||||||
"model": config.OLLAMA_DEFAULT_MODEL,
|
"model": config.OLLAMA_DEFAULT_MODEL,
|
||||||
"messages": [{"role": "user", "content": "What is 6 * 7? Use the calculator."}],
|
"messages": [{"role": "user", "content": "What is 6 * 7? Use the calculator."}],
|
||||||
"tools": [CALCULATOR_TOOL],
|
"tools": [CALCULATOR_TOOL],
|
||||||
"tool_choice": "required",
|
|
||||||
"stream": False,
|
"stream": False,
|
||||||
},
|
},
|
||||||
timeout=config.OLLAMA_TIMEOUT,
|
timeout=config.OLLAMA_TIMEOUT,
|
||||||
|
|||||||
Reference in New Issue
Block a user