Structured thinking as a pluggable layer for any OpenAI-compatible API
sotto voce — the passage spoken under the breath
| Getting Started | How It Works | Library | Proxy | Skills | Contributing |
Sotto gives any model a reasoning model's mechanism — a private thinking phase, effort control, interleaved thinking across tool calls, and skills — without changing your model, your SDK, or your code. The core is one stdlib-only file you can drop into any project. An optional proxy adds the same layer to any client just by repointing base_url.
Works with OpenAI · Azure OpenAI · vLLM · Ollama · DeepSeek · Groq · OpenRouter · LM Studio — anything that speaks /v1/chat/completions.
Latest Updates 🔥
- [2026/07] Initial public release of Sotto: single-file thinking engine, OpenAI-compatible proxy, and drop-in
SKILL.mdsupport.
Previous News
- Nothing here yet — you're early. ⭐ Star the repo to follow along.
About
Reasoning models (o-series, DeepSeek R1, gpt-5) share a set of mechanisms: they think privately before answering, scale that thinking by an effort setting, keep reasoning between tool calls, and hide the thinking from the final output. Sotto reproduces those mechanisms as a thin, portable layer so every model gets them — including the ones that don't reason natively.
| Reasoning-model concept | Sotto's implementation |
|---|---|
| Thinking blocks before the answer | <thinking> tags parsed out, or native reasoning_content harvested |
Adaptive thinking + effort (low/medium/high) |
ThinkingConfig.effort, mapped to reasoning_effort on o-series or a depth hint in the prompt |
Legacy budget_tokens |
ThinkingConfig.budget_tokens, auto-mapped to an effort tier |
| Interleaved thinking between tool calls | The run() loop: think → call tool → think about result → repeat |
| Thinking preserved during tool use | Raw assistant content (tags included) kept in message history across iterations |
| Thinking hidden from the user | Stripped from result.content; available in result.thinking and result.steps |
| Skills | SKILL.md folders, keyword-matched and injected into the system prompt per request |
Design goals
- Zero dependencies in the core.
thinking.pyis stdlib only. Drop it into any project, no install. - No lock-in. Any OpenAI-compatible client works. Repoint
base_urland you're done. - Progressive disclosure. Skills are injected only when a request matches, keeping context small.
- Graceful degradation. Weak models that skip the tags still produce a usable answer.
Getting Started
Install
pip install sotto-llm # core: zero dependencies, stdlib only
pip install "sotto-llm[proxy]" # + the proxy server (fastapi, uvicorn, openai, python-dotenv)
The core is still one self-contained stdlib-only file, so vendoring stays an option:
curl -O https://raw.githubusercontent.com/Madhav-Gohel/sotto/main/src/sotto_llm/thinking.py
Try it offline (no API key)
git clone https://github.com/Madhav-Gohel/sotto.git && cd sotto
pip install -e .
python example.py # scripted mock model runs the full think → tool → think → answer loop
How It Works
Sotto auto-selects one of three reasoning strategies based on the model:
visible_native— DeepSeek R1, QwQ, vLLM/Ollama reasoning models. The server already returnsmessage.reasoning_content; Sotto just harvests it.hidden_native— OpenAI o-series / gpt-5 family. Sotto passesreasoning_effort; reasoning happens server-side.prompted— everything else (gpt-4o-mini, llama, mistral…). Sotto injects a thinking protocol, the model reasons inside<thinking>tags, and Sotto parses them out. Unclosed tags (model cut off mid-thought) are handled.
Force a strategy with mode="prompted" | "native" | "off" instead of the default "auto".
Use as a Library
from openai import OpenAI
from sotto_llm import ThinkingEngine, ThinkingConfig
client = OpenAI(base_url="http://localhost:8000/v1") # vLLM, Ollama, Azure, anything
engine = ThinkingEngine(
client, "gpt-4o-mini",
config=ThinkingConfig(effort="high"),
tools=my_openai_tools,
tool_handlers={"get_weather": get_weather}, # python callables, run locally
skills_dir="skills",
)
result = engine.run([{"role": "user", "content": "weather in ahmedabad?"}])
result.content # final answer, thinking stripped
result.thinking # list of every thinking block, in order
result.steps # full interleaved trace: thinking / tool_call / tool_result / answer
result.as_openai_message() # {"role": "assistant", "content": ..., "reasoning_content": ...}
The engine runs a full think → act → think agentic loop, executing your tool handlers locally and feeding results back until the model produces a final answer (capped by max_tool_iterations).
Use as a Proxy
Any existing system — bots, agents, SDKs — gains thinking without code changes. Just repoint base_url.
pip install "sotto-llm[proxy]"
cp .env.example .env # set UPSTREAM_BASE_URL / UPSTREAM_API_KEY
uvicorn sotto_llm.proxy:app --port 8088
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8088/v1", api_key="x")
resp = client.chat.completions.create(
model="llama3.1",
messages=[{"role": "user", "content": "..."}],
extra_body={"thinking": {"effort": "high"}}, # optional; defaults apply without it
)
resp.choices[0].message.content # clean answer
resp.choices[0].message.reasoning_content # the thinking (DeepSeek/vLLM convention)
Reasoning comes back in message.reasoning_content — the same field DeepSeek and vLLM use — so existing clients keep working untouched. If the request includes tools, the proxy returns tool_calls to the caller like a normal OpenAI server (thinking still captured); the full agentic loop with local execution lives in library mode, since tools run on your side.
Skills
Each skill is a folder with a SKILL.md in the standard skill format: name and description in the frontmatter, instructions in the body. Existing skills in that format drop in unchanged.
---
name: code-review
description: Structured checklist for reviewing code changes... Use this skill
whenever the user asks you to review code, look at a PR or diff, or asks
whether some code "looks right" — even if they don't say "code review."
---
# Code Review
Review code in this order. The order matters: a fast, elegant function that
returns wrong answers is worse than a slow correct one...
The description does the triggering. Terms are extracted from the clause after "use this skill" — including quoted phrases and short domain tokens (pr, ci, sql) — then matched on word boundaries against the user's message. Only matching skills are injected, keeping context small (progressive disclosure).
keywords: a, b, c is supported as an optional extension. When present, it overrides description matching and a single hit triggers — useful when a skill needs to fire on jargon its description doesn't contain.
On matching quality: this is lexical matching, not semantic. It handles clear cases well but misses paraphrases that share no vocabulary with the description. If you have an embedding endpoint, swapping
match_skills()for cosine similarity over descriptions is a strict upgrade — the rest of the engine is unaffected.
Configuration Reference
| Field | Default | Meaning |
|---|---|---|
enabled |
True |
Turn the whole layer off with False |
effort |
"medium" |
low / medium / high reasoning depth |
budget_tokens |
None |
Legacy knob; <4096 low, <16384 medium, else high |
mode |
"auto" |
auto / prompted / native / off |
max_tool_iterations |
8 |
Safety cap on the agentic loop |
Proxy environment variables (via .env):
| Variable | Default | Meaning |
|---|---|---|
UPSTREAM_BASE_URL |
SDK default | The OpenAI-compatible server to forward to |
UPSTREAM_API_KEY |
x |
Key the upstream requires (any placeholder for local servers) |
SKILLS_DIR |
skills |
Directory the engine loads SKILL.md files from |
Limitations
- Prompted-mode thinking counts as normal output tokens (no separate billing lane like native reasoning).
- Tags are a convention, not enforcement; weak models occasionally skip them (the parser degrades gracefully — everything becomes the answer).
- The proxy is non-streaming; add SSE if you need it.
- The engine does not enforce a hard token cutoff on thinking; effort is a hint, matching how adaptive thinking treats it.
Contributing
Contributions are welcome — issues, feature requests, and PRs all help.
- Fork the repo and create a branch from
main. - Keep the core (
src/sotto_llm/thinking.py) dependency-free — stdlib only. New dependencies belong in the proxy extra or the examples. - If you touch matching or the reasoning loop, add or update a case in
example.pyso it runs offline. - Open a PR describing the change and the motivation.
Good first contributions: a streaming (SSE) proxy mode, an embedding-based match_skills() variant, and more example skills.
License
MIT — see LICENSE.
Citation
If you use Sotto in your work, please cite it:
@software{sotto,
title = {Sotto: Structured thinking as a pluggable layer for OpenAI-compatible APIs},
author = {Madhav Gohel},
year = {2026},
url = {https://github.com/Madhav-Gohel/sotto}
}
Built for anyone who wants their model to think first. ⭐ Star the repo if it's useful.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sotto_llm-0.1.0.tar.gz.
File metadata
- Download URL: sotto_llm-0.1.0.tar.gz
- Upload date:
- Size: 14.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b2992147a52dc939b75d5a5de27ffeadf5a7c5e2b75e2820093c4e12ad395710
|
|
| MD5 |
4aea47e147bce4a27366c8574c1141d9
|
|
| BLAKE2b-256 |
71cf09cd77968a97522992efd7b70e095cabf1a02c6b67ce6b207520f7c576a9
|
Provenance
The following attestation bundles were made for sotto_llm-0.1.0.tar.gz:
Publisher:
publish.yml on Madhav-Gohel/sotto
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sotto_llm-0.1.0.tar.gz -
Subject digest:
b2992147a52dc939b75d5a5de27ffeadf5a7c5e2b75e2820093c4e12ad395710 - Sigstore transparency entry: 2226362651
- Sigstore integration time:
-
Permalink:
Madhav-Gohel/sotto@c081c01476ba44ae8e8fe259983b024c4e05f562 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Madhav-Gohel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c081c01476ba44ae8e8fe259983b024c4e05f562 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file sotto_llm-0.1.0-py3-none-any.whl.
File metadata
- Download URL: sotto_llm-0.1.0-py3-none-any.whl
- Upload date:
- Size: 13.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e71943454ddb1ce78f71363765e6b4cbaad18bf1072f7558b9bfe4e15eefbbf1
|
|
| MD5 |
675d9e8aa7b4aa155c2887c487ae07be
|
|
| BLAKE2b-256 |
7ec619326b97143fb37bfe3ccc37d638c5ed5d5ab6aae104a7ca40de1b4670da
|
Provenance
The following attestation bundles were made for sotto_llm-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on Madhav-Gohel/sotto
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sotto_llm-0.1.0-py3-none-any.whl -
Subject digest:
e71943454ddb1ce78f71363765e6b4cbaad18bf1072f7558b9bfe4e15eefbbf1 - Sigstore transparency entry: 2226363171
- Sigstore integration time:
-
Permalink:
Madhav-Gohel/sotto@c081c01476ba44ae8e8fe259983b024c4e05f562 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Madhav-Gohel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c081c01476ba44ae8e8fe259983b024c4e05f562 -
Trigger Event:
workflow_dispatch
-
Statement type: