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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ account. The current catalog commonly includes:
## Features

- Tool / function calling
- Structured outputs via JSON schema (`response_format`, or `format` on the Ollama routes)
- Vision / image input
- Thinking summaries (via think tags)
- Configurable thinking effort
Expand Down
22 changes: 19 additions & 3 deletions chatmock/routes_ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@
build_reasoning_param,
extract_reasoning_from_model_name,
)
from .transform import convert_ollama_messages, normalize_ollama_tools
from .transform import (
convert_ollama_format_to_text_format,
convert_ollama_messages,
normalize_ollama_tools,
ollama_format_requests_json,
)
from .upstream import normalize_model_name, start_upstream_request
from .utils import convert_chat_messages_to_responses_input, convert_tools_chat_to_responses

Expand Down Expand Up @@ -198,6 +203,15 @@ def ollama_chat() -> Response:
tool_choice = payload.get("tool_choice", "auto")
parallel_tool_calls = bool(payload.get("parallel_tool_calls", False))

text_format = convert_ollama_format_to_text_format(payload.get("format"))

if (
ollama_format_requests_json(payload.get("format"))
and (reasoning_compat or "").strip().lower() == "think-tags"
):
# think tags would be prepended to content the caller asked to be json
reasoning_compat = "legacy"

# Passthrough Responses API tools (web_search) via ChatMock extension fields
extra_tools: List[Dict[str, Any]] = []
had_responses_tools = False
Expand Down Expand Up @@ -271,6 +285,7 @@ def ollama_chat() -> Response:
allowed_efforts=allowed_efforts_for_model(model),
),
service_tier=service_tier_resolution.service_tier,
text_format=text_format,
)
if error_resp is not None:
if verbose:
Expand Down Expand Up @@ -311,6 +326,7 @@ def ollama_chat() -> Response:
allowed_efforts=allowed_efforts_for_model(model),
),
service_tier=service_tier_resolution.service_tier,
text_format=text_format,
)
record_rate_limits_from_response(upstream2)
if err2 is None and upstream2 is not None and upstream2.status_code < 400:
Expand All @@ -333,7 +349,7 @@ def ollama_chat() -> Response:

if stream_req:
def _gen():
compat = (current_app.config.get("REASONING_COMPAT", "think-tags") or "think-tags").strip().lower()
compat = (reasoning_compat or "think-tags").strip().lower()
think_open = False
think_closed = False
saw_any_summary = False
Expand Down Expand Up @@ -551,7 +567,7 @@ def _gen():
finally:
upstream.close()

if (current_app.config.get("REASONING_COMPAT", "think-tags") or "think-tags").strip().lower() == "think-tags":
if (reasoning_compat or "think-tags").strip().lower() == "think-tags":
rtxt_parts = []
if isinstance(reasoning_summary_text, str) and reasoning_summary_text.strip():
rtxt_parts.append(reasoning_summary_text)
Expand Down
13 changes: 13 additions & 0 deletions chatmock/routes_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
from .upstream import normalize_model_name, start_upstream_raw_request, start_upstream_request
from .utils import (
convert_chat_messages_to_responses_input,
convert_response_format_to_text_format,
response_format_requests_json,
convert_tools_chat_to_responses,
sse_translate_chat,
sse_translate_text,
Expand Down Expand Up @@ -210,6 +212,15 @@ def chat_completions() -> Response:
if tier_error is not None:
return tier_error

text_format = convert_response_format_to_text_format(payload.get("response_format"))

if (
response_format_requests_json(payload.get("response_format"))
and (reasoning_compat or "").strip().lower() == "think-tags"
):
# think tags would be prepended to content the caller asked to be json
reasoning_compat = "legacy"

upstream, error_resp = start_upstream_request(
model,
input_items,
Expand All @@ -218,6 +229,7 @@ def chat_completions() -> Response:
parallel_tool_calls=parallel_tool_calls,
reasoning_param=reasoning_param,
service_tier=service_tier,
text_format=text_format,
)
if error_resp is not None:
if verbose:
Expand Down Expand Up @@ -255,6 +267,7 @@ def chat_completions() -> Response:
parallel_tool_calls=parallel_tool_calls,
reasoning_param=reasoning_param,
service_tier=service_tier,
text_format=text_format,
)
record_rate_limits_from_response(upstream2)
if err2 is None and upstream2 is not None and upstream2.status_code < 400:
Expand Down
17 changes: 17 additions & 0 deletions chatmock/transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,20 @@ def normalize_ollama_tools(tools: List[Dict[str, Any]] | None) -> List[Dict[str,
)
return out


def ollama_format_requests_json(fmt: Any) -> bool:
if isinstance(fmt, str):
return fmt.strip().lower() == "json"
return isinstance(fmt, dict) and bool(fmt)


def convert_ollama_format_to_text_format(fmt: Any) -> Dict[str, Any] | None:
"""Map Ollama's format, when it holds a schema, onto a Responses text.format.

The bare "json" string is left alone: upstream refuses json_object unless
the input mentions "json".
"""

if not isinstance(fmt, dict) or not fmt:
return None
return {"type": "json_schema", "name": "response", "schema": fmt, "strict": False}
3 changes: 3 additions & 0 deletions chatmock/upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def start_upstream_request(
parallel_tool_calls: bool = False,
reasoning_param: Dict[str, Any] | None = None,
service_tier: str | None = None,
text_format: Dict[str, Any] | None = None,
):
access_token, account_id = get_effective_chatgpt_auth()
if not access_token or not account_id:
Expand Down Expand Up @@ -86,6 +87,8 @@ def start_upstream_request(
responses_payload["reasoning"] = reasoning_param
if isinstance(service_tier, str) and service_tier.strip():
responses_payload["service_tier"] = service_tier.strip().lower()
if isinstance(text_format, dict) and text_format:
responses_payload["text"] = {"format": text_format}

return start_upstream_raw_request(
responses_payload,
Expand Down
40 changes: 40 additions & 0 deletions chatmock/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,46 @@ def convert_tools_chat_to_responses(tools: Any) -> List[Dict[str, Any]]:
return out


def convert_response_format_to_text_format(response_format: Any) -> Dict[str, Any] | None:
"""Map a Chat Completions response_format onto a Responses text.format."""

if not isinstance(response_format, dict):
return None

# json_object is deliberately not mapped: upstream rejects it unless the
# input mentions "json", so honouring it would 400 requests that pass today.
if response_format.get("type") != "json_schema":
return None

spec = response_format.get("json_schema")
if not isinstance(spec, dict):
spec = response_format
schema = spec.get("schema")
if not isinstance(schema, dict) or not schema:
return None

name = spec.get("name")
text_format: Dict[str, Any] = {
"type": "json_schema",
"name": name.strip() if isinstance(name, str) and name.strip() else "response",
"schema": schema,
"strict": bool(spec.get("strict")),
}
description = spec.get("description")
if isinstance(description, str) and description.strip():
text_format["description"] = description
return text_format


def response_format_requests_json(response_format: Any) -> bool:
"""Whether the caller asked for JSON content, mapped upstream or not."""

return isinstance(response_format, dict) and response_format.get("type") in (
"json_schema",
"json_object",
)


def load_chatgpt_tokens(
ensure_fresh: bool = True,
*,
Expand Down
Loading