models.json (any provider)
tsforge reads its model registry from ~/.tsforge/models.json (or $TSFORGE_HOME/.tsforge/models.json). It’s the one place you configure providers; you never need environment variables. active names the entry to use; /model <name> switches live.
{ "active": "deepseek", "models": { "deepseek": { "baseUrl": "https://api.deepseek.com/v1", "model": "deepseek-v4-pro", // ← the name SENT to the API (not the entry key) "apiKeyEnv": "DEEPSEEK_API_KEY", "reasoning": "deepseek", "reasoningEffort": "high" } }}Fields
Section titled “Fields”| Field | Required | Purpose |
|---|---|---|
baseUrl | yes | OpenAI-compatible API root, e.g. https://api.deepseek.com/v1. /chat/completions is appended (and not double-appended). |
model | yes | The model id sent to the API. |
apiKey / apiKeyEnv | no | Inline key, or the name of an env var holding it (preferred: keeps secrets out of the file). |
reasoning | no | Either a preset name (qwen default, deepseek, deepseek-local, openai, none) or a profile object declaring the field paths itself. See below. Validated at load. |
reasoningEffort | no | low / medium / high for deepseek/deepseek-local/openai. |
contextWindow | no | Tokens, for the status gauge + auto-compaction. Auto-detected from /models when omitted. |
maxTokens | no | Output-token cap (default 16384). |
extraBody | no | Arbitrary JSON merged into the request body (overrides built-ins). The escape hatch for any provider param. |
extraHeaders | no | Arbitrary request headers; ${VAR} values are interpolated from the environment. |
imageApi | no | For an imageGen capability entry only: wire shape. chat-modalities (default; /chat/completions with modalities:["image","text"], e.g. OpenRouter/Gemini) or images-generations (OpenAI /images/generations). |
Reasoning: presets, or declare your own
Section titled “Reasoning: presets, or declare your own”tsforge has three internal reasoning concepts — thinking on/off, effort, token budget — and every endpoint spells them differently on the wire. reasoning says how yours spells them. It takes either a preset name for a common case, or a profile object that declares the field paths itself.
The profile is the real interface. Presets are just aliases over it, so a model nobody has heard of is a config edit, not a code change and a release.
Presets
Section titled “Presets”| Name | Expands to |
|---|---|
qwen (default) | chat_template_kwargs.enable_thinking, budget at thinking_token_budget |
deepseek | thinking: { type: "enabled"|"disabled" }, effort at reasoning_effort, plus omitToolChoice, replayReasoning and latchThinking |
deepseek-local | chat_template_kwargs: { thinking, reasoning_effort }, budget at thinking_token_budget |
openai | effort at reasoning_effort, token cap renamed to max_completion_tokens, omitTemperature |
none | nothing |
Declaring a profile
Section titled “Declaring a profile”{ "baseUrl": "http://my-server:8000/v1", "model": "some-model", "reasoning": { "thinking": { "path": "params.reasoning.enabled" }, // dot path into the body "effort": "params.reasoning.level", "budget": "params.reasoning.max_tokens", "tokenCap": "output_limit", // default: max_tokens "omitTemperature": false, "omitToolChoice": false, "replayReasoning": false, "latchThinking": false }}Every field is optional, and an omitted control means the endpoint doesn’t have one, so nothing is sent for it. That’s the point: a field the server would accept-and-ignore is never sent in the first place.
thinking takes onValue/offValue when the flag isn’t a plain boolean:
"thinking": { "path": "mode", "onValue": { "kind": "deep" }, "offValue": { "kind": "off" } }The last three flags are behavioural rather than cosmetic, and exist because DeepSeek’s cloud API demands them: omitToolChoice drops an explicit tool_choice (it 400s on one, and the model still gets the tools and decides), replayReasoning re-sends each prior turn’s reasoning_content (it 400s without), and latchThinking pins thinking to the session’s first value (it 400s if it flips mid-conversation).
Auto-detection
Section titled “Auto-detection”When baseUrl/model contains “deepseek”, tsforge picks the dialect by address:
- private →
deepseek-local. Loopback,10.x,172.16–31.x,192.168.x,169.254.x, CGNAT100.64–100.127.x(Tailscale and friends), IPv6 loopback / unique-local (fc00::/7) / link-local (fe80::/10) / IPv4-mapped, and the.local.lan.internal.home.localdomainTLDs. - public →
deepseek, and each turn’sreasoning_contentis replayed on the next request (the cloud API 400s otherwise)
The split matters because the two speak different wire formats for the same feature. A locally served DeepSeek checkpoint reads chat_template_kwargs.thinking and silently ignores DeepSeek cloud’s thinking: { type } — it accepts the field and does nothing with it. Sending the cloud dialect to a local server therefore makes thinking inert: it only appears to work when the server’s own default happens to match what you wanted.
A single-label hostname such as http://spark2:8888 is deliberately NOT treated as private: it is indistinguishable from a proxy alias. Declare reasoning explicitly for those.
Detection is deliberately conservative on public hosts, since one may be a reverse proxy in front of DeepSeek cloud, which still needs the cloud dialect. Set reasoning explicitly for a self-hosted endpoint on a public address, or for a DeepSeek-compatible gateway that hides the string in its URL/model name.
Examples
Section titled “Examples”Local model (vLLM): the default
Section titled “Local model (vLLM): the default”{ "baseUrl": "http://localhost:8000/v1", "model": "deepseek-v4-flash", "thinking": true }DeepSeek
Section titled “DeepSeek”{ "baseUrl": "https://api.deepseek.com/v1", "model": "deepseek-v4-pro", "apiKeyEnv": "DEEPSEEK_API_KEY", "reasoning": "deepseek", "reasoningEffort": "high"}OpenAI o-series
Section titled “OpenAI o-series”{ "baseUrl": "https://api.openai.com/v1", "model": "o4-mini", "apiKeyEnv": "OPENAI_API_KEY", "reasoning": "openai", "reasoningEffort": "medium"}Extra capabilities (vision, image generation)
Section titled “Extra capabilities (vision, image generation)”The primary chat model is often text-only (e.g. a local DeepSeek). When it can’t read or generate images, route just those capabilities to a separate backend with a top-level capabilities block (a sibling of active/models, not inside models). Each value names a models entry, so the vision/image backend reuses the same endpoint config (key resolution, headers) as any chat model. When absent, the capability (and its read_image / generate_image tool, the drag/paste/@ attachment UX) stays off.
{ "active": "deepseek-v4-flash", // local chat, unchanged "capabilities": { "vision": "openrouter-vision", // reads images (drag / paste / @ / read_image) "imageGen": "openrouter-image" // generate_image → .tsforge/images/ }, "models": { "deepseek-v4-flash": { "baseUrl": "http://localhost:8000/v1", "model": "deepseek-ai/DeepSeek-V4-Flash", "thinking": false }, "openrouter-vision": { "baseUrl": "https://openrouter.ai/api/v1", "model": "google/gemini-2.5-flash-lite", "apiKey": "sk-or-..." }, "openrouter-image": { "baseUrl": "https://openrouter.ai/api/v1", "model": "google/gemini-3.1-flash-lite-image", "apiKey": "sk-or-...", "imageApi": "chat-modalities" } }}How it works: an attached image is sent to the vision backend and only its text description enters the conversation (the chat model stays text-only). generate_image calls the imageGen backend, saves the result under .tsforge/images/, and previews it inline in iTerm2. When local hardware arrives, point capabilities.vision/imageGen at a local entry. No code change needed.
Env overrides (an ad-hoc backend without editing the file): TSFORGE_VISION_BASE_URL / TSFORGE_VISION_MODEL / TSFORGE_VISION_API_KEY and the TSFORGE_IMAGE_* equivalents (+ TSFORGE_IMAGE_API). See the attachment UX and the flags reference.
Who reviews: reviewModels
Section titled “Who reviews: reviewModels”By default the active model runs the post-work code review on its own work. To use a different reviewer — or a panel — add a top-level reviewModels array (a sibling of active/models), each value naming a models entry:
{ "active": "local", "models": { "local": { "baseUrl": "http://localhost:8000/v1", "model": "deepseek-v4-flash" }, "haiku": { "baseUrl": "https://api.anthropic.com/v1", "model": "claude-haiku-4-5", "apiKeyEnv": "ANTHROPIC_API_KEY" } }, "reviewModels": ["haiku"]}One entry is a dedicated reviewer; several form a panel whose findings are pooled and deduped (more real issues caught). Absent ⇒ the active model reviews. A single ad-hoc reviewer can also be set by env — TSFORGE_REVIEW_MODEL (names a models entry) or the TSFORGE_REVIEW_BASE_URL / _MODEL / _API_KEY trio — which wins over reviewModels. (This is separate from reviewPanel, which is only for tsforge harness-review on tsforge’s own PRs.)
Any other provider: extraBody / extraHeaders
Section titled “Any other provider: extraBody / extraHeaders”When a provider needs a param or auth scheme tsforge doesn’t model, express it directly. extraBody is merged last (so it overrides anything), and extraHeaders can set a non-Bearer scheme:
{ "baseUrl": "https://my-gateway.example/v1", "model": "some-model", "reasoning": "none", "extraHeaders": { "api-key": "${MY_GATEWAY_KEY}" }, "extraBody": { "thinking": { "type": "enabled" }, "provider_specific_flag": true }}