Skip to content
ƒtsforgev0.52.0
19

models.json (any provider)

8 min read

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"
}
}
}
FieldRequiredPurpose
baseUrlyesOpenAI-compatible API root, e.g. https://api.deepseek.com/v1. /chat/completions is appended (and not double-appended).
modelyesThe model id sent to the API.
apiKey / apiKeyEnvnoInline key, or the name of an env var holding it (preferred: keeps secrets out of the file).
reasoningnoEither a preset name (qwen default, deepseek, deepseek-local, openai, none) or a profile object declaring the field paths itself. See below. Validated at load.
reasoningEffortnolow / medium / high for deepseek/deepseek-local/openai.
contextWindownoTokens, for the status gauge + auto-compaction. Auto-detected from /models when omitted.
maxTokensnoOutput-token cap (default 16384).
extraBodynoArbitrary JSON merged into the request body (overrides built-ins). The escape hatch for any provider param.
extraHeadersnoArbitrary request headers; ${VAR} values are interpolated from the environment.
imageApinoFor 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).

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.

NameExpands to
qwen (default)chat_template_kwargs.enable_thinking, budget at thinking_token_budget
deepseekthinking: { type: "enabled"|"disabled" }, effort at reasoning_effort, plus omitToolChoice, replayReasoning and latchThinking
deepseek-localchat_template_kwargs: { thinking, reasoning_effort }, budget at thinking_token_budget
openaieffort at reasoning_effort, token cap renamed to max_completion_tokens, omitTemperature
nonenothing
{
"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).

When baseUrl/model contains “deepseek”, tsforge picks the dialect by address:

  • privatedeepseek-local. Loopback, 10.x, 172.16–31.x, 192.168.x, 169.254.x, CGNAT 100.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 .localdomain TLDs.
  • publicdeepseek, and each turn’s reasoning_content is 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.

{ "baseUrl": "http://localhost:8000/v1", "model": "deepseek-v4-flash", "thinking": true }
{
"baseUrl": "https://api.deepseek.com/v1",
"model": "deepseek-v4-pro",
"apiKeyEnv": "DEEPSEEK_API_KEY",
"reasoning": "deepseek",
"reasoningEffort": "high"
}
{
"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.

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 }
}

Model adapter · Quickstart