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
12 changes: 9 additions & 3 deletions chatmock/reasoning.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,20 @@ def build_reasoning_param(
valid_efforts = allowed_efforts or DEFAULT_REASONING_EFFORTS
valid_summaries = {"auto", "concise", "detailed", "none"}

# A caller's explicit effort is forwarded when upstream knows it at all;
# upstream validates per model. Filtering against the catalog here silently
# rewrote efforts the catalog omits but upstream honours (e.g. `none`).
explicit_effort: str | None = None
if isinstance(overrides, dict):
o_eff = str(overrides.get("effort", "")).strip().lower()
o_sum = str(overrides.get("summary", "")).strip().lower()
if o_eff in valid_efforts and o_eff:
effort = o_eff
if o_eff in DEFAULT_REASONING_EFFORTS:
explicit_effort = o_eff
if o_sum in valid_summaries and o_sum:
summary = o_sum
if effort not in valid_efforts:
if explicit_effort is not None:
effort = explicit_effort
elif effort not in valid_efforts:
effort = "medium"
if summary not in valid_summaries:
summary = "auto"
Expand Down
38 changes: 26 additions & 12 deletions chatmock/routes_ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,18 @@
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
from .utils import (
convert_chat_messages_to_responses_input,
convert_tool_choice_chat_to_responses,
convert_tools_chat_to_responses,
)


ollama_bp = Blueprint("ollama", __name__)
Expand Down Expand Up @@ -183,21 +192,24 @@ def ollama_chat() -> Response:
messages = convert_ollama_messages(
raw_messages, payload.get("images") if isinstance(payload.get("images"), list) else None
)
if isinstance(messages, list):
sys_idx = next((i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "system"), None)
if isinstance(sys_idx, int):
sys_msg = messages.pop(sys_idx)
content = sys_msg.get("content") if isinstance(sys_msg, dict) else ""
messages.insert(0, {"role": "user", "content": content})
stream_req = payload.get("stream")
if stream_req is None:
stream_req = True
stream_req = bool(stream_req)
tools_req = payload.get("tools") if isinstance(payload.get("tools"), list) else []
tools_responses = convert_tools_chat_to_responses(normalize_ollama_tools(tools_req))
tool_choice = payload.get("tool_choice", "auto")
tool_choice = convert_tool_choice_chat_to_responses(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 +283,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 All @@ -297,7 +310,7 @@ def ollama_chat() -> Response:
if verbose:
print("[Passthrough] Upstream rejected tools; retrying without extras (args redacted)")
base_tools_only = convert_tools_chat_to_responses(normalize_ollama_tools(tools_req))
safe_choice = payload.get("tool_choice", "auto")
safe_choice = convert_tool_choice_chat_to_responses(payload.get("tool_choice", "auto"))
upstream2, err2 = start_upstream_request(
normalize_model_name(model, current_app.config.get("DEBUG_MODEL")),
input_items,
Expand All @@ -311,6 +324,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 +347,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 +565,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
58 changes: 26 additions & 32 deletions chatmock/routes_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
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,
convert_tool_choice_chat_to_responses,
convert_usage_responses_to_chat,
response_format_requests_json,
convert_tools_chat_to_responses,
sse_translate_chat,
sse_translate_text,
Expand Down Expand Up @@ -136,18 +140,12 @@ def chat_completions() -> Response:
_log_json("OUT POST /v1/chat/completions", err)
return jsonify(err), 400

if isinstance(messages, list):
sys_idx = next((i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "system"), None)
if isinstance(sys_idx, int):
sys_msg = messages.pop(sys_idx)
content = sys_msg.get("content") if isinstance(sys_msg, dict) else ""
messages.insert(0, {"role": "user", "content": content})
is_stream = bool(payload.get("stream"))
stream_options = payload.get("stream_options") if isinstance(payload.get("stream_options"), dict) else {}
include_usage = bool(stream_options.get("include_usage", False))

tools_responses = convert_tools_chat_to_responses(payload.get("tools"))
tool_choice = payload.get("tool_choice", "auto")
tool_choice = convert_tool_choice_chat_to_responses(payload.get("tool_choice", "auto"))
parallel_tool_calls = bool(payload.get("parallel_tool_calls", False))
responses_tools_payload = payload.get("responses_tools") if isinstance(payload.get("responses_tools"), list) else []
extra_tools: List[Dict[str, Any]] = []
Expand Down Expand Up @@ -210,6 +208,16 @@ 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"))
text_verbosity = payload.get("verbosity") if isinstance(payload.get("verbosity"), str) else None

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 +226,8 @@ def chat_completions() -> Response:
parallel_tool_calls=parallel_tool_calls,
reasoning_param=reasoning_param,
service_tier=service_tier,
text_format=text_format,
text_verbosity=text_verbosity,
)
if error_resp is not None:
if verbose:
Expand Down Expand Up @@ -246,7 +256,7 @@ def chat_completions() -> Response:
if verbose:
print("[Passthrough] Upstream rejected tools; retrying without extra tools (args redacted)")
base_tools_only = convert_tools_chat_to_responses(payload.get("tools"))
safe_choice = payload.get("tool_choice", "auto")
safe_choice = convert_tool_choice_chat_to_responses(payload.get("tool_choice", "auto"))
upstream2, err2 = start_upstream_request(
model,
input_items,
Expand All @@ -255,6 +265,8 @@ def chat_completions() -> Response:
parallel_tool_calls=parallel_tool_calls,
reasoning_param=reasoning_param,
service_tier=service_tier,
text_format=text_format,
text_verbosity=text_verbosity,
)
record_rate_limits_from_response(upstream2)
if err2 is None and upstream2 is not None and upstream2.status_code < 400:
Expand Down Expand Up @@ -306,19 +318,10 @@ def chat_completions() -> Response:
response_id = "chatcmpl"
tool_calls: List[Dict[str, Any]] = []
error_message: str | None = None
usage_obj: Dict[str, int] | None = None
usage_obj: Dict[str, Any] | None = None

def _extract_usage(evt: Dict[str, Any]) -> Dict[str, int] | None:
try:
usage = (evt.get("response") or {}).get("usage")
if not isinstance(usage, dict):
return None
pt = int(usage.get("input_tokens") or 0)
ct = int(usage.get("output_tokens") or 0)
tt = int(usage.get("total_tokens") or (pt + ct))
return {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": tt}
except Exception:
return None
def _extract_usage(evt: Dict[str, Any]) -> Dict[str, Any] | None:
return convert_usage_responses_to_chat((evt.get("response") or {}).get("usage"))
try:
for raw in upstream.iter_lines(decode_unicode=False):
if not raw:
Expand Down Expand Up @@ -503,18 +506,9 @@ def completions() -> Response:

full_text = ""
response_id = "cmpl"
usage_obj: Dict[str, int] | None = None
def _extract_usage(evt: Dict[str, Any]) -> Dict[str, int] | None:
try:
usage = (evt.get("response") or {}).get("usage")
if not isinstance(usage, dict):
return None
pt = int(usage.get("input_tokens") or 0)
ct = int(usage.get("output_tokens") or 0)
tt = int(usage.get("total_tokens") or (pt + ct))
return {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": tt}
except Exception:
return None
usage_obj: Dict[str, Any] | None = None
def _extract_usage(evt: Dict[str, Any]) -> Dict[str, Any] | None:
return convert_usage_responses_to_chat((evt.get("response") or {}).get("usage"))
try:
for raw_line in upstream.iter_lines(decode_unicode=False):
if not raw_line:
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}
11 changes: 10 additions & 1 deletion chatmock/upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ 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,
text_verbosity: str | None = None,
):
access_token, account_id = get_effective_chatgpt_auth()
if not access_token or not account_id:
Expand Down Expand Up @@ -71,7 +73,7 @@ def start_upstream_request(
"model": model,
"input": input_items,
"tools": tools or [],
"tool_choice": tool_choice if tool_choice in ("auto", "none") or isinstance(tool_choice, dict) else "auto",
"tool_choice": tool_choice if tool_choice in ("auto", "none", "required") or isinstance(tool_choice, dict) else "auto",
"parallel_tool_calls": bool(parallel_tool_calls),
"store": False,
"stream": True,
Expand All @@ -86,6 +88,13 @@ 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()
text: Dict[str, Any] = {}
if isinstance(text_format, dict) and text_format:
text["format"] = text_format
if isinstance(text_verbosity, str) and text_verbosity.strip():
text["verbosity"] = text_verbosity.strip().lower()
if text:
responses_payload["text"] = text

return start_upstream_raw_request(
responses_payload,
Expand Down
Loading