Custom Models in Oh My Pi: vLLM, llama.cpp, SGLang and More

ยท 1313 words ยท 7 minute read

Custom models let you run Oh My Pi (omp) on local models, or on models from providers it doesn’t support out of the box. omp reads them from ~/.omp/agent/models.yml. Below are working configs for the popular inference servers and for a gateway. After those come model roles, an allowlist that keeps omp off hosted models, thinking effort, fallbacks, and a way to log what omp sends.

There have been recent changes to omp. If you copied the config from my tuning post, update it for omp 18.2.7 and later:

  • Rename the provider from local to a name omp doesn’t already use, and update the modelRoles entries to match. omp now has its own local provider for small on-device models, so your Qwen model under that name stops resolving.
  • Add qwenTemplateReasoningEffort: true to the model’s compat block. Without it, omp stops sending Qwen 3.8 an effort level, and the model’s chat template picks xhigh every time.

Cartoon: a wooden wall cabinet of small pigeonholes under a sign reading local, each holding a tiny blue robot. A big orange robot wearing a name tag that reads qwen3.8-27b is crammed into a pigeonhole far too small for it, with its antenna bent and a question mark over its head. A small blue robot is squashed below it, and the robots next to it stare.

Inference servers ๐Ÿ”—

vLLM ๐Ÿ”—

For a single vLLM server, this is all you need in models.yml:

providers:
  vllm:
    baseUrl: http://192.168.1.20:8000/v1
    auth: none
    compat:
      extraBody:
        thinking_token_budget: 8192  # needs server support
    modelOverrides:
      qwen3.8-27b:                   # the name vLLM serves
        maxTokens: 32768

omp gets the model list and context window from vLLM itself, and sends Qwen 3.8’s effort level without any extra setting.

llama.cpp, LM Studio and Ollama ๐Ÿ”—

omp finds these on its own when they’re running locally on their default ports (8080, 1234, and 11434). For one on another machine, set LLAMA_CPP_BASE_URL, LM_STUDIO_BASE_URL, or OLLAMA_HOST, or add the URL to models.yml:

providers:
  llama.cpp:
    baseUrl: http://192.168.1.20:8080
    api: openai-responses
    auth: none
    discovery:
      type: llama.cpp

llama.cpp and LM Studio get Qwen 3.8’s effort level automatically, like vLLM.

SGLang, Lemonade, ninfer and the rest ๐Ÿ”—

These have no built-in provider, but they speak the OpenAI API, so declare one and let omp read the model list:

providers:
  sglang:
    baseUrl: http://192.168.1.20:30000/v1
    api: openai-completions
    auth: none
    discovery:
      type: openai-models-list
    compat:
      qwenTemplateReasoningEffort: true  # for Qwen 3.8

SGLang listens on port 30000, Lemonade on 13305, and ninfer on 8080, all under /v1. ninfer ignores thinking_token_budget, so set its budget with --default-thinking-budget when you start it. The omp-ninfer project has a tested omp setup for it.

Gateways and hand-listed models ๐Ÿ”—

A gateway, or two servers of the same kind, needs a provider of your own too. Give it a name omp doesn’t already use. I name mine after the gateway or machine behind it, and list the models by hand. Here’s the 27B entry from my Bifrost gateway:

providers:
  bifrost:
    baseUrl: http://192.168.1.10:8080/v1
    api: openai-completions
    apiKey: MY_GATEWAY_API_KEY               # env var (else literal)
    headers:
      x-bf-passthrough-extra-params: "true"  # or extraBody is dropped
    models:
      - id: rtx3090/qwen3.8-27b              # Bifrost's provider/model
        name: qwen3.8-27b
        contextWindow: 262144
        maxTokens: 32768
        reasoning: true
        input: [text]
        cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0}
        thinking:
          mode: effort
          efforts: [low, medium, xhigh]
          defaultLevel: medium
        compat:
          qwenTemplateReasoningEffort: true  # needed since 18.2.7
          extraBody:
            thinking_token_budget: 8192

Bifrost picks the backend from the part of the id before the slash. rtx3090/ is a box with two RTX 3090s, and strixhalo/ is the mini PC running Qwen3.8 Flash Next.

Cartoon: blue courier robots carry envelopes from a laptop on a desk labelled omp up a pastel rainbow bridge. At the top, a grey gatekeeper robot in a horned Viking helmet holds a golden horn and points the way. The bridge splits in two, with one path ending at a tall tower PC labelled rtx3090 and the other at a small mini PC labelled strixhalo, where couriers hand over their envelopes.

If apiKey starts with !, omp runs it as a command, which works with a password manager like 1Password: "!op read op://dev/gateway/key".

Model roles ๐Ÿ”—

modelRoles in ~/.omp/agent/config.yml decides which model does which job. default is the main agent and task runs subagents. A few more cover jobs like plan mode and commit messages. You can also set them from the Roles view in /model. A :level suffix sets the effort for that role, and I run subagents at low and plan mode at xhigh:

modelRoles:
  default: bifrost/strixhalo/qwen3.8-flash-next:medium
  plan: bifrost/strixhalo/qwen3.8-flash-next:xhigh
  task: bifrost/rtx3090/qwen3.8-27b:low
  smol: bifrost/rtx3090/qwen3.8-27b:low

Keeping omp on your own models ๐Ÿ”—

If a role’s model doesn’t resolve, or models.yml doesn’t parse, omp doesn’t stop. It falls back to the default model of a known provider it can use, and failing that, the first model it can use at all. It can use any provider that needs no key or that it has a key for. Keys come from logins, from a few dozen environment variables, some of which you probably set for other tools, like HF_TOKEN or AZURE_OPENAI_API_KEY, and from .env files, including one in the project you’re working in. With an AWS Bedrock token in the environment, the tuning post’s local config sent my test prompt to Claude Opus 5.5 on Bedrock.

An allowlist in config.yml prevents that:

enabledModels:
  - "bifrost/*"

Now omp only starts on a bifrost model, and stops at startup if none of them resolves. A project’s .omp/config.yml replaces this list rather than adding to it, so a project with its own list needs bifrost/* in it too.

Thinking effort ๐Ÿ”—

efforts lists the levels the model accepts, and defaultLevel is the one omp uses when a role has no suffix. Qwen 3.8 takes low, medium, and xhigh.

Keep the thinking_token_budget too. Together with the effort level, it’s what fixed the five-minute turns in the tuning post, and I’ve since raised it to 8,192 tokens without seeing the cap make the model worse at real work.

What the catalog fills in ๐Ÿ”—

If you list a model by hand and leave a field out, omp copies it from its built-in catalog. A bare qwen3.8-27b ends up with a hosted price, image input, and a 65,536-token reply limit. The price only changes omp’s cost estimate.

omp also ignores a misspelled key and uses the catalog’s value, so maxToken: 32768 gets you the catalog’s reply limit instead of yours. For your own providers, write out every field in the Bifrost example, and run omp models <provider> to verify.

Fallbacks ๐Ÿ”—

retry.fallbackChains in config.yml says what to try when a model keeps failing. A key can be a role, a model, or provider/*:

retry:
  fallbackChains:
    bifrost/strixhalo/qwen3.8-flash-next:
      - bifrost/rtx3090/qwen3.8-27b:medium
    bifrost/rtx3090/qwen3.8-27b:
      - bifrost/strixhalo/qwen3.8-flash-next:low

If a model’s server is down, omp moves to the next one in its chain and, by default, switches back on its own later. A hosted model can go in a chain if it’s also in enabledModels, but then omp can start on it when a role doesn’t resolve.

What changed in 18.2.7 ๐Ÿ”—

Both changes are in the 18.2.7 release notes. The local provider is under Added:

Added model-kind and grounded-search capability metadata, along with catalogs for local inference and search-engine models.

The effort change is under Changed:

Improved model routing and thinking-policy handling for llama.cpp Qwen models, Bonsai lineage aliases, and custom provider names.

Logging what omp sends ๐Ÿ”—

Cartoon: a laptop on a desk labelled omp is cabled to a server tower labelled GPU. Halfway along the cable, a small blue robot sits beside a receipt printer and reads the long paper strip coming out of it through a magnifying glass, which shows one highlighted line: effort: medium.

When omp does something odd with a model, I print what it’s sending. This simple script just prints each request body, minus the messages and tools, and answers “hi”:

import json
from http.server import BaseHTTPRequestHandler, HTTPServer


def sse_chunk(delta, finish):
    choice = {"index": 0, "delta": delta, "finish_reason": finish}
    chunk = {"object": "chat.completion.chunk", "choices": [choice]}
    return f"data: {json.dumps(chunk)}\n\n".encode()


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers["Content-Length"])
        body = json.loads(self.rfile.read(length))
        body.pop("messages", None)
        body.pop("tools", None)
        print(json.dumps(body, indent=2), flush=True)
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.end_headers()
        self.wfile.write(sse_chunk({"content": "hi"}, None))
        self.wfile.write(sse_chunk({}, "stop"))
        self.wfile.write(b"data: [DONE]\n\n")


HTTPServer(("127.0.0.1", 18080), Handler).serve_forever()

Copy your model into a scratch models.yml under a provider named test, with baseUrl: http://127.0.0.1:18080/v1, api: openai-completions and auth: none. Add enabledModels: ["test/*"] to a scratch config.yml so nothing can fall back to a hosted model. Put both files in one directory and point omp at it, using your model’s id:

mkdir -p /tmp/omp-test  # models.yml and config.yml go here
PI_CODING_AGENT_DIR=/tmp/omp-test \
  omp -p --no-session --model test/qwen3.8-27b:medium "Say hi."

I had help with this one. Anthropic’s Claude helped me test omp’s releases against the logging server and draft this post. I read all the words, checked the results, and rewrote anything that sounded like a chatbot, so the mistakes are mine.

Sources ๐Ÿ”—