Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/LOCAL_MODEL_SERVING.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,33 @@ biofoundry analyze-study \
- Increase concurrency for throughput only before freezing a confirmatory protocol.
Concurrency does not grant extra scheduled decisions, but it can change wall time and
may expose backend-specific sampling behavior.

## 8. Optional serving switches for other local servers

Two `llm` options, both off by default, help with servers whose Responses endpoint is
incomplete or whose models copy prompt placeholders into ID fields. With both off, request
bodies, prompts and the recorded `action_schema_sha256` are exactly as before.

```yaml
llm:
transport: chat_completions # default "responses"
extra_body:
chat_template_kwargs: {enable_thinking: false}
strict_id_patterns: true # default false
```

- `transport: chat_completions` posts to `{base_url}/chat/completions` with
`response_format: {type: json_schema, json_schema: {name, schema, strict}}`, the same schema
object the Responses transport sends in `text.format`. Use it when a server's `/v1/responses`
drops `text.format` (llama-server does) or does not exist. `max_tokens`, `temperature` and
`reasoning_effort` map one-to-one; usage is recorded under the Responses names so traces and
the compute audit stay uniform; `finish_reason: length` raises the same incomplete error, now
with the length, head and tail of the truncated reply; a leading `<think>...</think>` block is
stripped if a server returns reasoning inline.
- `extra_body` merges arbitrary fields into the request body of either transport, for
server-specific switches such as vLLM's `chat_template_kwargs`.
- `strict_id_patterns` adds a regex `pattern` to the three free-string ID fields of the action
schema (`reply_to`, `target_agent_id`, `target_artifact_id`) so constrained decoding cannot
emit placeholders such as `NONE`. It changes `action_schema_sha256`, so traces made with it
are self-labelled. The backend must honour `pattern`: on vLLM use
`--structured-outputs-config '{"backend":"guidance"}'` (xgrammar rejects the schema).
1 change: 1 addition & 0 deletions src/biofoundry/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def _reproducibility_metadata(config: GameConfig) -> dict[str, Any]:
allow_replies=replies_available(config),
resource_names=scenario.resource_names if scenario is not None else None,
operation_names=scenario.operation_names if scenario is not None else None,
strict_id_patterns=config.llm.strict_id_patterns,
)["schema"]
prompt_text = (
SCENARIO_SYSTEM_PROMPT.format(
Expand Down
12 changes: 12 additions & 0 deletions src/biofoundry/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ class LLMConfig:
# Work around constrained decoders that cannot terminate JSON number tokens.
# Numbers travel as short decimal strings and are coerced before validation.
grammar_safe_numbers: bool = False
# Replication-package addition (default off; see replication/patches/README.md). Small
# open-weight models copy the prompt's "use NONE when irrelevant" into the free-string ID
# fields, and the engine then rejects the whole action. With this flag the JSON schema
# constrains reply_to / target_agent_id / target_artifact_id to their ID grammar or "".
strict_id_patterns: bool = False
# Replication-package addition (default "responses"). "chat_completions" posts to
# {base_url}/chat/completions with response_format json_schema, for servers whose Responses
# route does not enforce text.format (llama-server converts /v1/responses internally and
# drops the schema). extra_body is merged into every request, e.g.
# {"chat_template_kwargs": {"enable_thinking": false}} for Qwen-style thinking models.
transport: str = "responses"
extra_body: dict[str, Any] | None = None
# Some local tokenizers strongly prefer legal JSON whitespace forever under a
# grammar. A negative bias on tokenizer tokens that decode only to whitespace
# preserves compact JSON while leaving content tokens unconstrained.
Expand Down
108 changes: 106 additions & 2 deletions src/biofoundry/providers/openai_compatible.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import copy
import http.client
import json
import os
Expand Down Expand Up @@ -94,6 +95,52 @@ def _whitespace_token_ids(
return tuple(token_ids)


def _chat_usage_as_responses(usage: dict[str, Any]) -> dict[str, Any]:
"""Map Chat Completions usage keys onto the Responses names the traces use."""
mapped: dict[str, Any] = {}
for src, dst in (
("prompt_tokens", "input_tokens"),
("completion_tokens", "output_tokens"),
("total_tokens", "total_tokens"),
):
if isinstance(usage.get(src), int):
mapped[dst] = usage[src]
return mapped


def _extract_chat_text(data: dict[str, Any]) -> str:
"""Extract assistant text from a non-streaming Chat Completions payload."""
error = data.get("error")
if error:
detail = str(error.get("message") or error) if isinstance(error, dict) else str(error)
raise ProviderError(f"model response failed: {detail[:1200]}")
choices = data.get("choices")
if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
raise ProviderError(f"Chat Completions payload contains no choices: {data}")
choice = choices[0]
finish = choice.get("finish_reason")
message = choice.get("message") or {}
if finish == "length":
# Keep the head and tail of the truncated text in the error so the trace shows what
# the model was doing when it hit max_tokens (whitespace loop, repeated field, ...).
partial = message.get("content") if isinstance(message.get("content"), str) else ""
tail = ""
if partial:
tail = f" | {len(partial)} chars, head {partial[:120]!r}, tail {partial[-160:]!r}"
raise ProviderError(
"model response status is 'incomplete': {'reason': 'max_output_tokens'}" + tail
)
if message.get("refusal"):
raise ProviderError(f"model refused the request: {str(message['refusal'])[:1200]}")
content = message.get("content")
if not isinstance(content, str) or not content:
raise ProviderError(f"Chat Completions payload contains no content: {data}")
# Thinking models without a server-side reasoning parser prepend their reasoning block.
if content.lstrip().startswith("<think>") and "</think>" in content:
content = content.split("</think>", 1)[1]
return content.strip()


def _extract_output_text(data: dict[str, Any]) -> str:
"""Extract assistant text from a non-streaming Responses API payload."""
error = data.get("error")
Expand Down Expand Up @@ -159,7 +206,11 @@ def __init__(
self.allow_replies = allow_replies
self.resource_names = None if resource_names is None else tuple(resource_names)
self.operation_names = None if operation_names is None else tuple(operation_names)
self.endpoint = config.base_url.rstrip("/") + "/responses"
if config.transport not in ("responses", "chat_completions"):
raise ValueError("llm.transport must be 'responses' or 'chat_completions'")
self.endpoint = config.base_url.rstrip("/") + (
"/chat/completions" if config.transport == "chat_completions" else "/responses"
)
self.semaphore = asyncio.Semaphore(config.concurrency)
self._json_whitespace_logit_bias: dict[str, float] = {}
if config.json_whitespace_logit_bias is not None:
Expand All @@ -185,6 +236,8 @@ async def generate_record(
return await asyncio.to_thread(self._generate_sync_record, messages)

def _request_body(self, messages: Sequence[ChatMessage]) -> dict[str, Any]:
if self.config.transport == "chat_completions":
return self._chat_completions_body(messages)
body = {
"model": self.config.model,
"input": [
Expand All @@ -208,12 +261,58 @@ def _request_body(self, messages: Sequence[ChatMessage]) -> dict[str, Any]:
allow_replies=self.allow_replies,
resource_names=self.resource_names,
operation_names=self.operation_names,
strict_id_patterns=self.config.strict_id_patterns,
)
body["text"] = {"format": output_format}
if self._json_whitespace_logit_bias:
body["logit_bias"] = dict(self._json_whitespace_logit_bias)
elif self.config.json_mode:
body["text"] = {"format": {"type": "json_object"}}
if self.config.extra_body:
body.update(copy.deepcopy(self.config.extra_body))
return body

def _output_format(self) -> dict[str, Any]:
return bounded_action_plan_text_format(
self.config.max_plan_actions,
grammar_safe_numbers=self.config.grammar_safe_numbers,
allowed_action_types=self.allowed_action_types,
allow_addressing=self.allow_addressing,
allow_replies=self.allow_replies,
resource_names=self.resource_names,
operation_names=self.operation_names,
strict_id_patterns=self.config.strict_id_patterns,
)

def _chat_completions_body(self, messages: Sequence[ChatMessage]) -> dict[str, Any]:
"""Replication-package addition: the same request as Chat Completions."""
body: dict[str, Any] = {
"model": self.config.model,
"messages": [
{"role": message.role, "content": message.content} for message in messages
],
"max_tokens": self.config.max_tokens,
}
if self.config.reasoning_effort is not None:
body["reasoning_effort"] = self.config.reasoning_effort
else:
body["temperature"] = self.config.temperature
if self.config.structured_output:
output_format = self._output_format()
body["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": output_format["name"],
"schema": output_format["schema"],
"strict": bool(output_format.get("strict", True)),
},
}
if self._json_whitespace_logit_bias:
body["logit_bias"] = dict(self._json_whitespace_logit_bias)
elif self.config.json_mode:
body["response_format"] = {"type": "json_object"}
if self.config.extra_body:
body.update(copy.deepcopy(self.config.extra_body))
return body

def _generate_sync(self, messages: Sequence[ChatMessage]) -> str:
Expand Down Expand Up @@ -262,8 +361,13 @@ def _generate_sync_record(
usage = data.get("usage", {})
if not isinstance(usage, dict):
usage = {}
if self.config.transport == "chat_completions":
usage = _chat_usage_as_responses(usage)
text = _extract_chat_text(data)
else:
text = _extract_output_text(data)
return GenerationResult(
text=_extract_output_text(data),
text=text,
usage={str(key): value for key, value in usage.items()},
metadata={
"response_id": str(data.get("id", "")),
Expand Down
16 changes: 16 additions & 0 deletions src/biofoundry/structured_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ def bounded_action_plan_text_format(
allow_replies: bool = True,
resource_names: Iterable[str] | None = None,
operation_names: Iterable[str] | None = None,
strict_id_patterns: bool = False,
) -> dict[str, Any]:
"""Return the exact provider contract for one experimental capability set."""

Expand Down Expand Up @@ -676,9 +677,24 @@ def bounded_action_plan_text_format(
if not retained:
raise ValueError("allowed_action_types must retain at least one action")
result["schema"]["$defs"]["action"]["anyOf"] = retained
if strict_id_patterns:
# Replication-package addition: pin free-string ID fields to their ID grammar or the
# empty string so constrained decoders cannot emit placeholders such as "NONE".
for branch in result["schema"]["$defs"]["action"]["anyOf"]:
for field, prefix in ID_FIELD_PREFIXES.items():
prop = branch["properties"].get(field)
if isinstance(prop, dict) and "enum" not in prop:
prop["pattern"] = f"^({prefix}_[0-9]+)?$"
return result


ID_FIELD_PREFIXES = {
"reply_to": "message",
"target_agent_id": "agent",
"target_artifact_id": "artifact",
}


def coerce_transport_numbers(value: Any) -> Any:
"""Convert grammar-safe numeric strings to canonical plan numbers in-place."""

Expand Down
103 changes: 103 additions & 0 deletions tests/test_local_model_serving.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for the opt-in local-model serving options: chat-completions transport, extra_body,
constrained ID fields, and the truncated-reply detail on finish_reason=length."""

import hashlib
import json

import pytest

from biofoundry.config import LLMConfig
from biofoundry.providers.base import ChatMessage
from biofoundry.providers.openai_compatible import (
OpenAICompatibleProvider,
ProviderError,
_chat_usage_as_responses,
_extract_chat_text,
)
from biofoundry.structured_output import ACTION_PLAN_SCHEMA, bounded_action_plan_text_format


def _schema_hash(**kwargs: object) -> str:
schema = bounded_action_plan_text_format(12, **kwargs)["schema"]
return hashlib.sha256(json.dumps(schema, sort_keys=True).encode()).hexdigest()


def test_strict_id_patterns_off_leaves_schema_unchanged() -> None:
assert _schema_hash() == _schema_hash(strict_id_patterns=False)


def test_strict_id_patterns_constrains_id_fields_and_changes_hash() -> None:
assert _schema_hash(strict_id_patterns=True) != _schema_hash()
schema = bounded_action_plan_text_format(12, strict_id_patterns=True)["schema"]
text = json.dumps(schema)
assert "^(message_[0-9]+)?$" in text
assert "^(agent_[0-9]+)?$" in text
assert "^(artifact_[0-9]+)?$" in text
# the plain schema carries no such patterns
assert "message_[0-9]" not in json.dumps(bounded_action_plan_text_format(12)["schema"])


def test_chat_completions_transport_builds_openai_chat_body() -> None:
provider = OpenAICompatibleProvider(
LLMConfig(
model="local-qwen",
max_tokens=4096,
structured_output=True,
transport="chat_completions",
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
)
messages = [ChatMessage("system", "Act."), ChatMessage("user", "Return JSON.")]
body = provider._request_body(messages)
assert provider.endpoint == "http://127.0.0.1:8000/v1/chat/completions"
assert body["messages"] == [
{"role": "system", "content": "Act."},
{"role": "user", "content": "Return JSON."},
]
assert body["max_tokens"] == 4096
assert body["temperature"] == 0.7
assert body["response_format"] == {
"type": "json_schema",
"json_schema": {
"name": "biofoundry_agent_plan",
"strict": True,
"schema": ACTION_PLAN_SCHEMA,
},
}
assert body["chat_template_kwargs"] == {"enable_thinking": False}
assert "input" not in body and "text" not in body and "store" not in body


def test_default_transport_is_unchanged_responses_shape() -> None:
provider = OpenAICompatibleProvider(
LLMConfig(model="gpt-5.6-luna", max_tokens=64, structured_output=True)
)
body = provider._request_body([ChatMessage("user", "x")])
assert provider.endpoint.endswith("/responses")
assert "input" in body and "response_format" not in body


def test_chat_usage_maps_to_responses_names() -> None:
usage = {"prompt_tokens": 12, "completion_tokens": 3, "total_tokens": 15}
assert _chat_usage_as_responses(usage) == {
"input_tokens": 12,
"output_tokens": 3,
"total_tokens": 15,
}


def test_chat_text_extraction_and_think_block_strip() -> None:
content = '<think>\nplan\n</think>\n{"verb":3}'
data = {"choices": [{"finish_reason": "stop", "message": {"content": content}}]}
assert _extract_chat_text(data) == '{"verb":3}'


def test_chat_length_finish_raises_incomplete_with_tail() -> None:
partial = "{" + "x" * 300
data = {"choices": [{"finish_reason": "length", "message": {"content": partial}}]}
with pytest.raises(ProviderError) as info:
_extract_chat_text(data)
message = str(info.value)
assert "max_output_tokens" in message
assert "301 chars" in message
assert partial[:20] in message and partial[-20:] in message