Skip to main content

Sotto

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 |

PyPI Python 3.10+ MIT License Zero dependencies PRs welcome GitHub stars


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.md support.
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.py is stdlib only. Drop it into any project, no install.
  • No lock-in. Any OpenAI-compatible client works. Repoint base_url and 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:

  1. visible_native — DeepSeek R1, QwQ, vLLM/Ollama reasoning models. The server already returns message.reasoning_content; Sotto just harvests it.
  2. hidden_native — OpenAI o-series / gpt-5 family. Sotto passes reasoning_effort; reasoning happens server-side.
  3. 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.

  1. Fork the repo and create a branch from main.
  2. Keep the core (src/sotto_llm/thinking.py) dependency-free — stdlib only. New dependencies belong in the proxy extra or the examples.
  3. If you touch matching or the reasoning loop, add or update a case in example.py so it runs offline.
  4. 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

sotto_llm-0.1.0.tar.gz (14.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sotto_llm-0.1.0-py3-none-any.whl (13.9 kB view details)

Uploaded Python 3

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

Hashes for sotto_llm-0.1.0.tar.gz
Algorithm Hash digest
SHA256 b2992147a52dc939b75d5a5de27ffeadf5a7c5e2b75e2820093c4e12ad395710
MD5 4aea47e147bce4a27366c8574c1141d9
BLAKE2b-256 71cf09cd77968a97522992efd7b70e095cabf1a02c6b67ce6b207520f7c576a9

See more details on using hashes here.

Provenance

The following attestation bundles were made for sotto_llm-0.1.0.tar.gz:

Publisher: publish.yml on Madhav-Gohel/sotto

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for sotto_llm-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e71943454ddb1ce78f71363765e6b4cbaad18bf1072f7558b9bfe4e15eefbbf1
MD5 675d9e8aa7b4aa155c2887c487ae07be
BLAKE2b-256 7ec619326b97143fb37bfe3ccc37d638c5ed5d5ab6aae104a7ca40de1b4670da

See more details on using hashes here.

Provenance

The following attestation bundles were made for sotto_llm-0.1.0-py3-none-any.whl:

Publisher: publish.yml on Madhav-Gohel/sotto

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page