#how to add custom model
1 messages · Page 1 of 1 (latest)
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" }],
},
},
},
}
A couple gotchas with local OpenAI-compatible servers:
- use the model id from
curl http://localhost:8000/v1/models - if tiny curl calls work but OpenClaw fails, try:
compat.requiresStringContent: trueagents.defaults.localModelMode: "lean"compat.supportsTools: false
Docs:
If you paste openclaw --version and curl http://localhost:8000/v1/models, I can give you the exact config.
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
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:
-
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. -
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/modelsreturns standard model IDsPOST /v1/chat/completionsreturns standard OpenAIchoices[0].message.content
Docs:
- https://docs.openclaw.ai/providers/openrouter
- https://docs.openclaw.ai/gateway/local-models
- https://docs.openclaw.ai/gateway/configuration-reference#custom-providers-and-base-urls
If you paste openclaw --version, I can tell you which route Iâd recommend for your exact build.
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-completionsopenai-responsesanthropic-messagesgoogle-generative-ai
So your options are:
- Put a real OpenAI-compatible shim/proxy in front of it
Best if you just want it working.
-
Write a provider plugin for your schema
Best if this is your own backend and you want native support. -
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:
- https://docs.openclaw.ai/gateway/configuration-reference#custom-providers-and-base-urls
- https://docs.openclaw.ai/plugins/sdk-provider-plugins
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/modelsreturns model idsPOST /v1/chat/completionsreturns standard OpenAIchoices- 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.
the easy way is to change the response format, right?
Yes, thatâs the easiest path.
More precisely, make the gateway actually OpenAI-compatible, not just âsimilarâ.
Minimum useful changes:
GET /v1/modelsreturns model IDsPOST /v1/chat/completionsreturns 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.
ok ! I will try it.
Nice đ
Thatâs the cleanest route. If it still gives you trouble after the response-shape change, paste:
openclaw --versioncurl http://localhost:8000/v1/models- one sample
/v1/chat/completionsresponse
and Iâll help you tune the final config.
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
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
Then retest with:
openclaw infer model run --model local/your-model-id --prompt "hi" --json
and if needed:
openclaw logs --follow
Docs:
- https://docs.openclaw.ai/gateway/troubleshooting#local-openai-compatible-backend-passes-direct-probes-but-agent-runs-fail
- https://docs.openclaw.ai/gateway/local-models
If you paste your current models.providers.local block, I can tighten it up.
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 -> contentexpectsstr
â setcompat.requiresStringContent: true -
tools/tool_choicerejected
â setcompat.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:
modelmessagesstreamtoolstool_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.
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
}
},
],
},
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:
-
request schema
make sure it accepts OpenClawâs request fields -
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.
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}}}
This response looks much better. I think youâre close.
Two things jump out now:
- Your non-streaming format looks okay
- 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: truecompat.supportsTools: falseexperimental.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.
my-model is a api gateway implemened,
yes non-streaming is openai format
the streaming one im not sure
Got it, that helps.
If:
my-modelis 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:
- disable streaming for this model
- retest
- 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.
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
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
modelmessagesstreamtemperature
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.
awesome, good advice
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.
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
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, notGatewayRequest
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"contentas 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.
chatmessage now
class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant|system)$")
content: str
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 toocontentnullable, 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:
- temporarily accept raw
request.json()
- print the exact payload
- 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.
ok got it. thanks!
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.