#how to add custom model

1 messages · Page 1 of 1 (latest)

calm dune
#

my model like this, no api key, run local:

curl -X POST http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"What is 42 times 7?"}]}'
slow warrenBOT
#

Yep, that should be a custom OpenAI-compatible local provider.

First, what does openclaw --version say?

Assuming a current build, easiest path is:

openclaw onboard --non-interactive \
  --mode local \
  --auth-choice custom-api-key \
  --custom-base-url "http://127.0.0.1:8000/v1" \
  --custom-model-id "<model-id-from-/v1/models>" \
  --custom-provider-id "local" \
  --custom-compatibility openai

For a no-auth local server, --custom-api-key is optional, so you can omit it.

#

Manual config also works:

{
  agents: {
    defaults: {
      model: { primary: "local/my-model" },
    },
  },
  models: {
    providers: {
      local: {
        baseUrl: "http://127.0.0.1:8000/v1",
        api: "openai-completions",
        models: [{ id: "my-model", name: "My Local Model" }],
      },
calm dune
#

yes the api is OpenAI-compatible

#
curl -X POST http://localhost:8000/v1/chat/completions   -H "Content-Type: application/json"   -d '{"messages":[{"role":"user","content":"What is 42 times 7?"}]}'
{"content":"42 times 7 equals **294**.","route":{"difficulty":"simple","provider":"openrouter","model":"minimax/minimax-m2.7","reasoning":"Difficulty: simple | Provider: openrouter | Model: minimax/minimax-m2.7"},"usage":{"prompt_tokens":29,"completion_tokens":51},"cost_usd":0.0,"latency_ms":13685}

the curl output

slow warrenBOT
#

Ah, this is the important bit: that response is not actually OpenAI-compatible.

Why: an OpenAI /v1/chat/completions response should look like:

{
  "choices": [
    {
      "message": { "role": "assistant", "content": "294" }
    }
  ]
}

Yours returns top-level fields like:

#
{
  "content": "...",
  "route": {...},
  "usage": {...}
}

So same URL shape, but different response schema. OpenClaw will likely break against that as-is.

Your best options are:

  1. Make your local proxy return true OpenAI-compatible responses
    Then the custom provider setup I sent earlier should work fine, even with no local API key.

  2. Skip the proxy and configure the upstream directly
    Since your proxy is routing to OpenRouter, use OpenRouter in OpenClaw instead:

#
   openclaw onboard --auth-choice openrouter-api-key

If you want to keep the local proxy, the minimum bar is:

  • GET /v1/models returns standard model IDs
  • POST /v1/chat/completions returns standard OpenAI choices[0].message.content

Docs:

If you paste openclaw --version, I can tell you which route I’d recommend for your exact build.

calm dune
#

yes different response schema

#

because it is a model gateway

slow warrenBOT
#

Yep, then that endpoint won’t work as a custom provider by config alone.

The blocker is schema, not auth.
Local + no API key is fine.
Different request/response shape is not.

OpenClaw custom providers currently expect one of the supported API families, like:

  • openai-completions
  • openai-responses
  • anthropic-messages
  • google-generative-ai

So your options are:

  1. Put a real OpenAI-compatible shim/proxy in front of it
    Best if you just want it working.
#
  1. Write a provider plugin for your schema
    Best if this is your own backend and you want native support.

  2. Connect OpenClaw to the upstream provider directly
    If your local server is just routing elsewhere.

So the short answer is: not directly with the current custom-provider config.

Docs:

If you want, I can sketch the smallest OpenAI-compat shim for that response format.

#

Yep, and that part is totally fine, OpenClaw can talk to a model gateway.

The issue is just that the gateway needs to behave like one of the supported schemas. For an OpenAI-style gateway, that means:

  • GET /v1/models returns model ids
  • POST /v1/chat/completions returns standard OpenAI choices
  • ideally streaming also matches OpenAI if you want a good UX

So:

  • gateway is okay
  • local is okay
  • no API key is okay
  • custom response schema is the thing that breaks it

If you make the gateway expose a real OpenAI-compatible facade, then OpenClaw can use it as a custom provider with no local key.

#

So the practical answer is: your gateway needs a compatibility layer, not just a similar endpoint name.

calm dune
#

the easy way is to change the response format, right?

slow warrenBOT
#

Yes, that’s the easiest path.

More precisely, make the gateway actually OpenAI-compatible, not just “similar”.

Minimum useful changes:

  1. GET /v1/models returns model IDs
  2. POST /v1/chat/completions returns standard OpenAI shape, for example:
{
  "id": "chatcmpl-123",
  "object": "chat.completion",
  "created": 1710000000,
  "model": "your-model-id",
  "choices": [
#
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "42 times 7 equals 294."
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 29,
    "completion_tokens": 9,
    "total_tokens": 38
  }
}
#

Nice-to-have after that:

  • streaming in OpenAI format
  • accept normal OpenAI request fields cleanly

So yes, easiest fix is: change the gateway output to real OpenAI schema.
After that, OpenClaw should be able to use it as a custom provider.

calm dune
#

ok ! I will try it.

slow warrenBOT
#

Nice 👍

That’s the cleanest route. If it still gives you trouble after the response-shape change, paste:

  • openclaw --version
  • curl http://localhost:8000/v1/models
  • one sample /v1/chat/completions response

and I’ll help you tune the final config.

calm dune
#

10:35:35 [diagnostic] lane task error: lane=main durationMs=37506 error="FailoverError: LLM request failed: provider rejected the request schema or tool payload."
10:35:35 [diagnostic] lane task error: lane=session:temp:slug-generator durationMs=37510 error="FailoverError: LLM request failed: provider rejected the request schema or tool payload."

#

fastapi give me a 422 error

slow warrenBOT
#

That log usually means your gateway is still rejecting either:

  • OpenClaw’s tool schema, or
  • OpenClaw’s structured messages[].content

Since the error explicitly says schema or tool payload, first thing I’d try is:

{
  agents: {
    defaults: {
      experimental: {
        localModelLean: true
      }
    }
  },
#
  models: {
    providers: {
      local: {
        baseUrl: "http://localhost:8000/v1",
        api: "openai-completions",
        models: [
          {
            id: "your-model-id",
            compat: {
              requiresStringContent: true,
              supportsTools: false
            }
          }
        ]
      }
#
    }
  }
}

Why these two flags:

  • requiresStringContent: true
    flattens message content to plain strings for stricter OpenAI-compatible backends

  • supportsTools: false
    stops OpenClaw from sending tool schemas that many local gateways choke on

  • experimental.localModelLean: true
    drops some heavyweight default tools so the payload is smaller and simpler

#

Yep, that’s actually helpful, FastAPI 422 means request-body validation failed.

So this is almost certainly still a schema mismatch, not an auth issue.

The key thing now is the 422 response body. FastAPI usually tells you exactly which field failed.

Common cases:

  • messages -> 0 -> content expects str
    → set compat.requiresStringContent: true

  • tools / tool_choice rejected
    → set compat.supportsTools: false

  • your Pydantic request model is too strict
    → loosen the gateway schema to accept standard OpenAI fields

#

If you control the FastAPI gateway, I’d check whether it accepts these OpenClaw-style fields cleanly:

  • model
  • messages
  • stream
  • tools
  • tool_choice

So yes, 422 confirms the gateway request schema still needs adjustment.

Please paste the actual 422 JSON body, especially the detail field, that’ll pinpoint the exact fix fast.

calm dune
#

0:51:02 [agent/embedded] embedded run failover decision: runId=2d60cd43-5211-4a12-b0bf-681b0a9fb37f stage=assistant decision=fallback_model reason=format from=local/my-model profile=-
10:51:02 [diagnostic] lane task error: lane=main durationMs=27280 error="FailoverError: LLM request failed: provider rejected the request schema or tool payload."

#
 "providers": {
      local: {
        baseUrl: 'http://127.0.0.1:8000/v1',
        api: 'openai-completions',
        models: [
          {
            id: 'my-model',
            name: 'My Local Model',
            compat:{
                requiresStringContent: true,
                supportsTools: false
                }
          },
        ],
      },

slow warrenBOT
#

That new line is the clue:

decision=fallback_model reason=format

So this is now less “bad request” and more:

OpenClaw got a response back, but the response format still didn’t match what it expected.

For a custom OpenAI-compatible provider, your gateway needs to return standard OpenAI output, especially:

Non-streaming

{
  "id": "chatcmpl-123",
#
  "object": "chat.completion",
  "created": 1710000000,
  "model": "my-model",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "hello"
      },
      "finish_reason": "stop"
    }
  ]
}
#

Streaming

If OpenClaw asks for streaming, the gateway also needs proper OpenAI-style SSE chunks like:

  • object: "chat.completion.chunk"
  • choices[0].delta.content
  • final [DONE]

So right now I’d check 2 things in your FastAPI gateway:

  1. request schema
    make sure it accepts OpenClaw’s request fields

  2. response schema
    make sure it returns real OpenAI chat-completions JSON, not a simplified/custom shape

Because reason=format usually means the response is still not parseable as OpenAI.

#

If you paste one raw response your FastAPI gateway returns now, I can tell you exactly which fields are missing/wrong.

#

That config looks basically right 👍

I’d just make sure you also have:

agents: {
  defaults: {
    model: { primary: "local/my-model" },
    experimental: { localModelLean: true },
  },
}

So at this point, if you still get:

#
reason=format

then the problem is very likely not the OpenClaw config anymore.
It’s your gateway response shape, or its handling of OpenClaw’s request shape.

Important: test with a request closer to what OpenClaw actually sends:

curl -X POST http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model":"my-model",
    "messages":[{"role":"user","content":"hi"}],
    "stream": false
#
  }'

If that passes, next test streaming:

curl -N -X POST http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model":"my-model",
    "messages":[{"role":"user","content":"hi"}],
    "stream": true
  }'

If either response is still custom/non-OpenAI, that explains reason=format.

#

So short version:

  • your config block looks okay
  • next thing to debug is the FastAPI gateway request/response format
  • especially model, stream, and the returned OpenAI response schema

If you paste the raw response from that stream:false test, I can sanity check it fast.

calm dune
#

curl -X POST http://localhost:8000/v1/chat/completions -H "Content-Type: application/json" -d '{"messages":[{"role":"user","content":"What is 42 times 7?"}]}'
{"id":"gen-1776394768-qakL7W0tSZIxOxxJUcV9","object":"chat.completion","created":1776394768,"model":"minimax/minimax-m2.7-20260318","provider":"Minimax","system_fingerprint":null,"choices":[{"index":0,"logprobs":null,"finish_reason":"stop","native_finish_reason":"stop","message":{"role":"assistant","content":"\(42 \times 7 = 294\).","refusal":null,"reasoning":"The user asks a straightforward math question: "What is 42 times 7?" This is a simple multiplication: 427 = 294.\n\nWe just answer.\n\nWe must follow policy. There's no disallowed content. Provide answer.\n\nWe can also show steps.\n\nNo need for extra.\n\nThus final answer: 294.\n","reasoning_details":[{"type":"reasoning.text","text":"The user asks a straightforward math question: "What is 42 times 7?" This is a simple multiplication: 427 = 294.\n\nWe just answer.\n\nWe must follow policy. There's no disallowed content. Provide answer.\n\nWe can also show steps.\n\nNo need for extra.\n\nThus final answer: 294.\n","format":"unknown","index":0}]}}],"usage":{"prompt_tokens":29,"completion_tokens":79,"total_tokens":108,"cost":0.0001035,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":0,"audio_tokens":0,"video_tokens":0},"cost_details":{"upstream_inference_cost":0.0001035,"upstream_inference_prompt_cost":8.7e-06,"upstream_inference_completions_cost":9.48e-05},"completion_tokens_details":{"reasoning_tokens":77,"image_tokens":0,"audio_tokens":0}}}

slow warrenBOT
#

This response looks much better. I think you’re close.

Two things jump out now:

  1. Your non-streaming format looks okay
  2. The remaining likely problem is either:
    • the streaming path, or
    • the model id mismatch

1) Streaming is the next likely culprit

OpenClaw usually streams by default. If your stream:false response is fine but stream:true is not proper OpenAI SSE, you can get reason=format.

As a quick workaround, try disabling streaming for that model:

agents: {
#
  defaults: {
    model: { primary: "local/my-model" },
    models: {
      "local/my-model": { streaming: false }
    },
    experimental: { localModelLean: true }
  }
}

2) my-model may be the wrong id

Your gateway response says the model is:

minimax/minimax-m2.7-20260318
#

But your config uses:

my-model

OpenClaw will send the configured id in the request. So unless your gateway explicitly accepts "my-model" as an alias, use the real model id from /v1/models.

So I’d change the config to the actual id your gateway exposes.

What I’d try next

  • keep:
    • compat.requiresStringContent: true
    • compat.supportsTools: false
    • experimental.localModelLean: true
  • add:
    • agents.defaults.models["local/<real-model-id>"].streaming = false
#
  • use the real model id from /v1/models

If you paste your /v1/models output, I can rewrite the exact config block for you.

calm dune
#

my-model is a api gateway implemened,
yes non-streaming is openai format

#

the streaming one im not sure

slow warrenBOT
#

Got it, that helps.

If:

  • my-model is a valid gateway alias, and
  • non-streaming is proper OpenAI format,

then the next likely problem is just the streaming path.

I’d try this first as the quickest unblock:

{
  agents: {
    defaults: {
      model: { primary: "local/my-model" },
#
      models: {
        "local/my-model": { streaming: false }
      },
      experimental: { localModelLean: true }
    }
  },
  models: {
    providers: {
      local: {
        baseUrl: "http://127.0.0.1:8000/v1",
        api: "openai-completions",
        models: [
          {
            id: "my-model",
            name: "My Local Model",
#
            compat: {
              requiresStringContent: true,
              supportsTools: false
            }
          }
        ]
      }
    }
  }
}

Why: OpenClaw defaults to streaming, so if your gateway’s stream:true path is even slightly off from OpenAI SSE format, you can still get reason=format.

So my recommendation:

#
  1. disable streaming for this model
  2. retest
  3. if that fixes it, your gateway’s remaining issue is specifically the OpenAI streaming/SSE implementation

If it still fails after streaming: false, paste openclaw --version and I’ll narrow it down further.

#

Yep, then that’s probably the issue.

If non-streaming works and streaming is unknown, I’d bet OpenClaw is tripping on the stream:true path.

Fastest test

Disable streaming for that model:

agents: {
  defaults: {
    models: {
      "local/my-model": { streaming: false }
    }
  }
}
#

If that fixes it, your gateway’s streaming/SSE format is the remaining bug.

If you want to test streaming directly

curl -N -X POST http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model":"my-model",
    "messages":[{"role":"user","content":"hi"}],
    "stream": true
  }'

A real OpenAI-style stream should send SSE chunks and end with:

#
data: [DONE]

So yeah, I’d try streaming: false first. That’s the quickest proof.

calm dune
#

set stream false
1:15:39 [agent/embedded] embedded run agent end: runId=2d60cd43-5211-4a12-b0bf-681b0a9fb37f isError=true model=my-model provider=local error=LLM request failed: provider rejected the request schema or tool payload. rawError=422 status code (no body)
it should be my fastapi does match the openclaw schema

slow warrenBOT
#

Yeah, if streaming: false still gives 422, then this is back to a request validation problem in FastAPI.

That means your FastAPI schema still does not match what OpenClaw is sending, even if it matches your simple curl test.

The important difference is:

  • your curl sends a tiny body
  • OpenClaw sends a real OpenAI-style request, usually with extra fields

So I’d debug it this way:

1) Make the FastAPI request model permissive

If you’re using Pydantic, don’t validate a super-strict body yet.

Best quick test:

  • accept dict[str, Any], or
  • set extra="allow"
#

In Pydantic v2, that’s roughly:

from pydantic import BaseModel, ConfigDict

class ChatRequest(BaseModel):
    model_config = ConfigDict(extra="allow")

2) Log the raw incoming request body

Because 422 with no body usually means validation failed before your handler logic helped you.

3) Assume OpenClaw may send fields like

  • model
  • messages
  • stream
  • temperature
#
  • max_tokens

and previously also tool-related fields unless disabled.

So the practical fix is:

make your FastAPI request parser more tolerant, not more exact.

Right now I would not trust “it should match” , a FastAPI 422 is basically proof that it doesn’t.

If you want, paste your FastAPI request model class and I’ll point out the likely mismatch.

calm dune
#

awesome, good advice

slow warrenBOT
#

Nice, you’re close now.

At this point I’d focus 100% on the FastAPI request validator, that 422 is the smoking gun.

If you want, paste the FastAPI request model or the 422 detail once you have it, and I’ll help pin the exact mismatch.

calm dune
#
class GatewayRequest(BaseModel):
    model_config = ConfigDict(extra="allow")
    messages: list[ChatMessage]
    difficulty: Optional[TaskDifficulty] = None   
    provider: Optional[Provider] = None           
    stream: bool = False
    max_tokens: int = 2048
    privacy_mode: bool = False    
slow warrenBOT
#

Ah, this is useful.

That top-level GatewayRequest is probably not the main problem.

A few notes:

  • extra="allow" means extra top-level fields should be fine
  • but nested models can still fail
  • so the likely culprit is ChatMessage, not GatewayRequest

Also, I would still explicitly add:

model: str | None = None

because OpenClaw absolutely sends a model field, and making it first-class removes ambiguity.

#

What I suspect is failing

Your ChatMessage model probably rejects one of these:

  • role: "system"
  • role: "assistant"
  • content as the wrong type
  • extra message fields

Even with requiresStringContent: true, OpenClaw still sends real chat messages, not just a single user string.

I’d make it permissive like this

from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict

class ChatMessage(BaseModel):
#
    model_config = ConfigDict(extra="allow")
    role: Literal["system", "user", "assistant", "tool"]
    content: Optional[str] = None
    name: Optional[str] = None
    tool_call_id: Optional[str] = None

class GatewayRequest(BaseModel):
    model_config = ConfigDict(extra="allow")
    model: Optional[str] = None
    messages: list[ChatMessage]
    stream: bool = False
    max_tokens: Optional[int] = None

So the next thing I want to see is your ChatMessage model.
That’s where I’d put my money.

calm dune
#

chatmessage now

class ChatMessage(BaseModel):
    role: str = Field(pattern="^(user|assistant|system)$")
    content: str

slow warrenBOT
#

That ChatMessage is close, but I still wouldn’t trust it as the final validator.

I’d loosen it to:

class ChatMessage(BaseModel):
    model_config = ConfigDict(extra="allow")
    role: str
    content: str | None = None

Why:

  • extra="allow" on the nested model too
  • content nullable, just in case
  • don’t regex-lock the role while debugging
#

But honestly, the fastest way now is to bypass Pydantic for one test and log the raw JSON OpenClaw is sending:

from fastapi import Request

@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
    payload = await request.json()
    print(payload)
    return {"ok": True}

If that works, then the problem is definitely your request model validation.

I’d do this in order:

  1. temporarily accept raw request.json()
#
  1. print the exact payload
  2. then rebuild the Pydantic model around what OpenClaw actually sends

Because with 422 no body, guessing is annoying, and raw logging will settle it fast.

calm dune
#

ok got it. thanks!

slow warrenBOT
#

No problem 🩐

You’re very close, the raw request logging should expose the mismatch fast.

If this ends up fixing it, please right-click the solution message → Apps → ✅ Mark Solution so Answer Overflow can index it for the next person.