feat(gateway): make openai compatibility agent-first

This commit is contained in:
Vincent Koc
2026-03-24 18:02:57 -07:00
parent e1d16ba42e
commit d10669629d
13 changed files with 234 additions and 133 deletions

View File

@@ -93,9 +93,9 @@ Why this set matters:
Planning note:
- Keep `/v1/models` as a flat `provider/model` list for client compatibility.
- Treat agent and sub-agent selection as separate OpenClaw routing concerns, not pseudo-model entries.
- When you need agent-scoped filtering, pass `x-openclaw-agent-id` on both model-list and request calls.
- `/v1/models` is agent-first: it returns `openclaw`, `openclaw/default`, and `openclaw/<agentId>`.
- `openclaw/default` is the stable alias that always maps to the configured default agent.
- Use `x-openclaw-model` when you want a backend provider/model override; otherwise the selected agent's normal model and embedding setup stays in control.
All of these run on the main Gateway port and use the same trusted operator auth boundary as the rest of the Gateway HTTP API.

View File

@@ -48,26 +48,25 @@ Treat this endpoint as a **full operator-access** surface for the gateway instan
See [Security](/gateway/security) and [Remote access](/gateway/remote).
## Choosing an agent
## Agent-first model contract
No custom headers required: encode the agent id in the OpenAI `model` field:
OpenClaw treats the OpenAI `model` field as an **agent target**, not a raw provider model id.
- `model: "openclaw:<agentId>"` (example: `"openclaw:main"`, `"openclaw:beta"`)
- `model: "agent:<agentId>"` (alias)
- `model: "openclaw"` routes to the configured default agent.
- `model: "openclaw/default"` also routes to the configured default agent.
- `model: "openclaw/<agentId>"` routes to a specific agent.
Or target a specific OpenClaw agent by header:
Optional request headers:
- `x-openclaw-agent-id: <agentId>` (default: `main`)
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent.
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
Advanced:
Compatibility aliases still accepted:
- `x-openclaw-session-key: <sessionKey>` to fully control session routing.
- `x-openclaw-message-channel: <channel>` to set the synthetic ingress channel context for channel-aware prompts and policies.
For `/v1/models` and `/v1/embeddings`, `x-openclaw-agent-id` is still useful:
- `/v1/models` uses it for agent-scoped model filtering where relevant.
- `/v1/embeddings` uses it to resolve agent-specific memory-search embedding config.
- `model: "openclaw:<agentId>"`
- `model: "agent:<agentId>"`
## Enabling the endpoint
@@ -120,34 +119,40 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
<AccordionGroup>
<Accordion title="What does `/v1/models` return?">
A flat OpenAI-style model list.
An OpenClaw agent-target list.
The returned ids are canonical `provider/model` values such as `openai/gpt-5.4`.
These ids are meant to be passed back directly as the OpenAI `model` field.
The returned ids are `openclaw`, `openclaw/default`, and `openclaw/<agentId>` entries.
Use them directly as OpenAI `model` values.
</Accordion>
<Accordion title="Does `/v1/models` list agents or sub-agents?">
No.
It lists top-level agent targets, not backend provider models and not sub-agents.
`/v1/models` lists model choices, not execution topology. Agents and sub-agents are OpenClaw routing concerns, so they are selected separately with `x-openclaw-agent-id` or the `openclaw:<agentId>` / `agent:<agentId>` model aliases on chat and responses requests.
Sub-agents remain internal execution topology. They do not appear as pseudo-models.
</Accordion>
<Accordion title="How does agent-scoped filtering work?">
Send `x-openclaw-agent-id: <agentId>` when you want the model list for a specific agent.
<Accordion title="Why is `openclaw/default` included?">
`openclaw/default` is the stable alias for the configured default agent.
OpenClaw filters the model list against that agent's allowed models and fallbacks when configured. If no allowlist is configured, the endpoint returns the full catalog.
That means clients can keep using one predictable id even if the real default agent id changes between environments.
</Accordion>
<Accordion title="How do sub-agents pick a model?">
Sub-agent model choice is resolved at spawn time from OpenClaw agent config.
<Accordion title="How do I override the backend model?">
Use `x-openclaw-model`.
That means sub-agent model selection does not create extra `/v1/models` entries. Keep the compatibility list flat, and treat agent and sub-agent selection as separate OpenClaw-native routing behavior.
Examples:
`x-openclaw-model: openai/gpt-5.4`
`x-openclaw-model: gpt-5.4`
If you omit it, the selected agent runs with its normal configured model choice.
</Accordion>
<Accordion title="What should clients do in practice?">
Use `/v1/models` to populate the normal model picker.
<Accordion title="How do embeddings fit this contract?">
`/v1/embeddings` uses the same agent-target `model` ids.
If your client or integration also knows which OpenClaw agent it wants, set `x-openclaw-agent-id` when listing models and when sending chat, responses, or embeddings requests. That keeps the picker aligned with the target agent's allowed model set.
Use `model: "openclaw/default"` or `model: "openclaw/<agentId>"`.
When you need a specific embedding model, send it in `x-openclaw-model`.
Without that header, the request passes through to the selected agent's normal embedding setup.
</Accordion>
</AccordionGroup>
@@ -168,9 +173,8 @@ Non-streaming:
curl -sS http://127.0.0.1:18789/v1/chat/completions \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-agent-id: main' \
-d '{
"model": "openclaw",
"model": "openclaw/default",
"messages": [{"role":"user","content":"hi"}]
}'
```
@@ -181,9 +185,9 @@ Streaming:
curl -N http://127.0.0.1:18789/v1/chat/completions \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-agent-id: main' \
-H 'x-openclaw-model: openai/gpt-5.4' \
-d '{
"model": "openclaw",
"model": "openclaw/research",
"stream": true,
"messages": [{"role":"user","content":"hi"}]
}'
@@ -199,7 +203,7 @@ curl -sS http://127.0.0.1:18789/v1/models \
Fetch one model:
```bash
curl -sS http://127.0.0.1:18789/v1/models/openai%2Fgpt-5.4 \
curl -sS http://127.0.0.1:18789/v1/models/openclaw%2Fdefault \
-H 'Authorization: Bearer YOUR_TOKEN'
```
@@ -209,15 +213,16 @@ Create embeddings:
curl -sS http://127.0.0.1:18789/v1/embeddings \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-openclaw-agent-id: main' \
-H 'x-openclaw-model: openai/text-embedding-3-small' \
-d '{
"model": "openai/text-embedding-3-small",
"model": "openclaw/default",
"input": ["alpha", "beta"]
}'
```
Notes:
- `/v1/models` returns canonical ids in `provider/model` form so they can be passed back directly as OpenAI `model` values.
- `/v1/models` stays flat on purpose: it does not enumerate agents or sub-agents as pseudo-model ids.
- `/v1/models` returns OpenClaw agent targets, not raw provider catalogs.
- `openclaw/default` is always present so one stable id works across environments.
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field.
- `/v1/embeddings` supports `input` as a string or array of strings.

View File

@@ -24,7 +24,8 @@ Operational behavior matches [OpenAI Chat Completions](/gateway/openai-http-api)
- use `Authorization: Bearer <token>` with the normal Gateway auth config
- treat the endpoint as full operator access for the gateway instance
- select agents with `model: "openclaw:<agentId>"`, `model: "agent:<agentId>"`, or `x-openclaw-agent-id`
- select agents with `model: "openclaw"`, `model: "openclaw/default"`, `model: "openclaw/<agentId>"`, or `x-openclaw-agent-id`
- use `x-openclaw-model` when you want to override the selected agent's backend model
- use `x-openclaw-session-key` for explicit session routing
- use `x-openclaw-message-channel` when you want a non-default synthetic ingress channel context
@@ -37,7 +38,7 @@ The same compatibility surface also includes:
- `POST /v1/embeddings`
- `POST /v1/chat/completions`
For the canonical explanation of how model listing, agent routing, and sub-agent model selection fit together, see [OpenAI Chat Completions](/gateway/openai-http-api#model-list-and-agent-routing).
For the canonical explanation of how agent-target models, `openclaw/default`, embeddings pass-through, and backend model overrides fit together, see [OpenAI Chat Completions](/gateway/openai-http-api#agent-first-model-contract) and [Model list and agent routing](/gateway/openai-http-api#model-list-and-agent-routing).
## Session behavior

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js";
import { normalizeGoogleApiBaseUrl } from "../infra/google-api-base-url.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
buildAnthropicVertexProvider,

View File

@@ -62,7 +62,7 @@ async function postEmbeddings(body: unknown, headers?: Record<string, string>) {
describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
it("embeds string and array inputs", async () => {
const single = await postEmbeddings({
model: "text-embedding-3-small",
model: "openclaw/default",
input: "hello",
});
expect(single.status).toBe(200);
@@ -75,7 +75,7 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
expect(singleJson.data?.[0]?.embedding).toEqual([0.1, 0.2]);
const batch = await postEmbeddings({
model: "text-embedding-3-small",
model: "openclaw/default",
input: ["a", "b"],
});
expect(batch.status).toBe(200);
@@ -87,13 +87,16 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
{ object: "embedding", index: 1, embedding: [1.1, 1.2] },
]);
const qualified = await postEmbeddings({
model: "openai/text-embedding-3-small",
input: "hello again",
});
const qualified = await postEmbeddings(
{
model: "openclaw/default",
input: "hello again",
},
{ "x-openclaw-model": "openai/text-embedding-3-small" },
);
expect(qualified.status).toBe(200);
const qualifiedJson = (await qualified.json()) as { model?: string };
expect(qualifiedJson.model).toBe("openai/text-embedding-3-small");
expect(qualifiedJson.model).toBe("openclaw/default");
const lastCall = createEmbeddingProviderMock.mock.calls.at(-1)?.[0] as
| { provider?: string; model?: string }
| undefined;
@@ -106,7 +109,7 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
it("supports base64 encoding and agent-scoped auth/config resolution", async () => {
const res = await postEmbeddings(
{
model: "text-embedding-3-small",
model: "openclaw/beta",
input: "hello",
encoding_format: "base64",
},
@@ -119,14 +122,14 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
const lastCall = createEmbeddingProviderMock.mock.calls.at(-1)?.[0] as
| { provider?: string; model?: string; fallback?: string; agentDir?: string }
| undefined;
expect(lastCall?.model).toBe("text-embedding-3-small");
expect(typeof lastCall?.model).toBe("string");
expect(lastCall?.fallback).toBe("none");
expect(lastCall?.agentDir).toBe(resolveAgentDir({}, "beta"));
});
it("rejects invalid input shapes", async () => {
const res = await postEmbeddings({
model: "text-embedding-3-small",
model: "openclaw/default",
input: [{ nope: true }],
});
expect(res.status).toBe(400);
@@ -134,13 +137,29 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
expect(json.error?.type).toBe("invalid_request_error");
});
it("rejects disallowed provider-prefixed model overrides", async () => {
it("rejects invalid agent targets", async () => {
const res = await postEmbeddings({
model: "ollama/nomic-embed-text",
input: "hello",
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error).toEqual({
type: "invalid_request_error",
message: "Invalid `model`. Use `openclaw` or `openclaw/<agentId>`.",
});
});
it("rejects disallowed x-openclaw-model provider overrides", async () => {
const res = await postEmbeddings(
{
model: "openclaw/default",
input: "hello",
},
{ "x-openclaw-model": "ollama/nomic-embed-text" },
);
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error).toEqual({
type: "invalid_request_error",
message: "This agent does not allow that embedding provider on `/v1/embeddings`.",
@@ -149,7 +168,7 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
it("rejects oversized batches", async () => {
const res = await postEmbeddings({
model: "text-embedding-3-small",
model: "openclaw/default",
input: Array.from({ length: 129 }, () => "x"),
});
expect(res.status).toBe(400);
@@ -163,7 +182,7 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
it("sanitizes provider failures", async () => {
createEmbeddingProviderMock.mockRejectedValueOnce(new Error("secret upstream failure"));
const res = await postEmbeddings({
model: "text-embedding-3-small",
model: "openclaw/default",
input: "hello",
});
expect(res.status).toBe(500);

View File

@@ -14,7 +14,12 @@ import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { sendJson } from "./http-common.js";
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
import { resolveAgentIdFromHeader } from "./http-utils.js";
import {
OPENCLAW_MODEL_ID,
getHeader,
resolveAgentIdForRequest,
resolveAgentIdFromModel,
} from "./http-utils.js";
type OpenAiEmbeddingsHttpOptions = {
auth: ResolvedGatewayAuth;
@@ -148,6 +153,17 @@ export async function handleOpenAiEmbeddingsHttpRequest(
return true;
}
const cfg = loadConfig();
if (requestModel !== OPENCLAW_MODEL_ID && !resolveAgentIdFromModel(requestModel, cfg)) {
sendJson(res, 400, {
error: {
message: "Invalid `model`. Use `openclaw` or `openclaw/<agentId>`.",
type: "invalid_request_error",
},
});
return true;
}
const texts = resolveInputTexts(payload.input);
if (!texts) {
sendJson(res, 400, {
@@ -166,15 +182,12 @@ export async function handleOpenAiEmbeddingsHttpRequest(
return true;
}
const cfg = loadConfig();
const agentId = resolveAgentIdFromHeader(req) ?? "main";
const agentId = resolveAgentIdForRequest({ req, model: requestModel });
const agentDir = resolveAgentDir(cfg, agentId);
const memorySearch = resolveMemorySearchConfig(cfg, agentId);
const configuredProvider = (memorySearch?.provider ?? "openai") as EmbeddingProviderRequest;
const target = resolveEmbeddingsTarget({
requestModel,
configuredProvider,
});
const overrideModel = getHeader(req, "x-openclaw-model")?.trim() || memorySearch?.model || "";
const target = resolveEmbeddingsTarget({ requestModel: overrideModel, configuredProvider });
if ("errorMessage" in target) {
sendJson(res, 400, {
error: {
@@ -229,10 +242,7 @@ export async function handleOpenAiEmbeddingsHttpRequest(
index,
embedding: encodingFormat === "base64" ? encodeEmbeddingBase64(embedding) : embedding,
})),
model:
requestModel.includes("/") || target.provider === "auto"
? requestModel
: `${target.provider}/${target.model}`,
model: requestModel,
usage: {
prompt_tokens: 0,
total_tokens: 0,

View File

@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import type { IncomingMessage } from "node:http";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
buildAllowedModelSet,
isCliProvider,
@@ -12,6 +13,9 @@ import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-k
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
export const OPENCLAW_MODEL_ID = "openclaw";
export const OPENCLAW_DEFAULT_MODEL_ID = "openclaw/default";
export function getHeader(req: IncomingMessage, name: string): string | undefined {
const raw = req.headers[name.toLowerCase()];
if (typeof raw === "string") {
@@ -43,11 +47,18 @@ export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefin
return normalizeAgentId(raw);
}
export function resolveAgentIdFromModel(model: string | undefined): string | undefined {
export function resolveAgentIdFromModel(
model: string | undefined,
cfg = loadConfig(),
): string | undefined {
const raw = model?.trim();
if (!raw) {
return undefined;
}
const lowered = raw.toLowerCase();
if (lowered === OPENCLAW_MODEL_ID || lowered === OPENCLAW_DEFAULT_MODEL_ID) {
return resolveDefaultAgentId(cfg);
}
const m =
raw.match(/^openclaw[:/](?<agentId>[a-z0-9][a-z0-9_-]{0,63})$/i) ??
@@ -60,27 +71,28 @@ export function resolveAgentIdFromModel(model: string | undefined): string | und
}
export async function resolveOpenAiCompatModelOverride(params: {
req: IncomingMessage;
agentId: string;
model: string | undefined;
}): Promise<{ modelOverride?: string; errorMessage?: string }> {
const model = params.model;
const raw = model?.trim();
const requestModel = params.model?.trim();
if (requestModel && !resolveAgentIdFromModel(requestModel)) {
return {
errorMessage: "Invalid `model`. Use `openclaw` or `openclaw/<agentId>`.",
};
}
const raw = getHeader(params.req, "x-openclaw-model")?.trim();
if (!raw) {
return {};
}
if (raw.toLowerCase() === "openclaw") {
return {};
}
if (resolveAgentIdFromModel(raw)) {
return {};
}
const cfg = loadConfig();
const defaultModelRef = resolveDefaultModelForAgent({ cfg, agentId: params.agentId });
const defaultProvider = defaultModelRef.provider;
const parsed = parseModelRef(raw, defaultProvider);
if (!parsed) {
return { errorMessage: "Invalid `model`." };
return { errorMessage: "Invalid `x-openclaw-model`." };
}
const catalog = await loadGatewayModelCatalog();
@@ -108,13 +120,14 @@ export function resolveAgentIdForRequest(params: {
req: IncomingMessage;
model: string | undefined;
}): string {
const cfg = loadConfig();
const fromHeader = resolveAgentIdFromHeader(params.req);
if (fromHeader) {
return fromHeader;
}
const fromModel = resolveAgentIdFromModel(params.model);
return fromModel ?? "main";
const fromModel = resolveAgentIdFromModel(params.model, cfg);
return fromModel ?? resolveDefaultAgentId(cfg);
}
export function resolveSessionKey(params: {

View File

@@ -43,8 +43,10 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => {
expect(json.object).toBe("list");
expect(Array.isArray(json.data)).toBe(true);
expect((json.data?.length ?? 0) > 0).toBe(true);
expect(json.data?.map((entry) => entry.id)).toContain("openclaw");
expect(json.data?.map((entry) => entry.id)).toContain("openclaw/default");
expect(
json.data?.every((entry) => typeof entry.id === "string" && entry.id?.includes("/")),
json.data?.every((entry) => typeof entry.id === "string" && entry.id?.startsWith("openclaw")),
).toBe(true);
});

View File

@@ -1,14 +1,15 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import { buildAllowedModelSet, modelKey, parseModelRef } from "../agents/model-selection.js";
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { loadConfig } from "../config/config.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js";
import { sendInvalidRequest, sendJson, sendMethodNotAllowed } from "./http-common.js";
import { resolveAgentIdForRequest } from "./http-utils.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
import {
OPENCLAW_DEFAULT_MODEL_ID,
OPENCLAW_MODEL_ID,
resolveAgentIdFromModel,
} from "./http-utils.js";
type OpenAiModelsHttpOptions = {
auth: ResolvedGatewayAuth;
@@ -23,21 +24,15 @@ type OpenAiModelObject = {
created: number;
owned_by: string;
permission: [];
input?: ModelCatalogEntry["input"];
context_window?: number;
reasoning?: boolean;
};
function toOpenAiModel(entry: ModelCatalogEntry): OpenAiModelObject {
function toOpenAiModel(id: string): OpenAiModelObject {
return {
id: modelKey(entry.provider, entry.id),
id,
object: "model",
created: 0,
owned_by: entry.provider,
owned_by: "openclaw",
permission: [],
...(entry.input ? { input: entry.input } : {}),
...(typeof entry.contextWindow === "number" ? { context_window: entry.contextWindow } : {}),
...(typeof entry.reasoning === "boolean" ? { reasoning: entry.reasoning } : {}),
};
}
@@ -56,17 +51,15 @@ async function authorizeRequest(
});
}
async function loadAllowedCatalog(req: IncomingMessage): Promise<ModelCatalogEntry[]> {
function loadAgentModelIds(): string[] {
const cfg = loadConfig();
const catalog = await loadGatewayModelCatalog();
const agentId = resolveAgentIdForRequest({ req, model: undefined });
const { allowedCatalog } = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: DEFAULT_PROVIDER,
agentId,
});
return allowedCatalog.length > 0 ? allowedCatalog : catalog;
const defaultAgentId = resolveDefaultAgentId(cfg);
const ids = new Set<string>([OPENCLAW_MODEL_ID, OPENCLAW_DEFAULT_MODEL_ID]);
ids.add(`openclaw/${defaultAgentId}`);
for (const agentId of listAgentIds(cfg)) {
ids.add(`openclaw/${agentId}`);
}
return Array.from(ids);
}
function resolveRequestPath(req: IncomingMessage): string {
@@ -92,11 +85,11 @@ export async function handleOpenAiModelsHttpRequest(
return true;
}
const catalog = await loadAllowedCatalog(req);
const ids = loadAgentModelIds();
if (requestPath === "/v1/models") {
sendJson(res, 200, {
object: "list",
data: catalog.map(toOpenAiModel),
data: ids.map(toOpenAiModel),
});
return true;
}
@@ -115,15 +108,12 @@ export async function handleOpenAiModelsHttpRequest(
return true;
}
const parsed = parseModelRef(decodedId, DEFAULT_PROVIDER);
if (!parsed) {
if (decodedId !== OPENCLAW_MODEL_ID && !resolveAgentIdFromModel(decodedId)) {
sendInvalidRequest(res, "Invalid model id.");
return true;
}
const key = modelKey(parsed.provider, parsed.model);
const entry = catalog.find((item) => modelKey(item.provider, item.id) === key);
if (!entry) {
if (!ids.includes(decodedId)) {
sendJson(res, 404, {
error: {
message: `Model '${decodedId}' not found.`,
@@ -133,6 +123,6 @@ export async function handleOpenAiModelsHttpRequest(
return true;
}
sendJson(res, 200, toOpenAiModel(entry));
sendJson(res, 200, toOpenAiModel(decodedId));
return true;
}

View File

@@ -202,7 +202,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
{
await expectAgentSessionKeyMatch({
body: {
model: "openclaw:beta",
model: "openclaw/beta",
messages: [{ role: "user", content: "hi" }],
},
matcher: /^agent:beta:/,
@@ -212,11 +212,10 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
{
await expectAgentSessionKeyMatch({
body: {
model: "openclaw:beta",
model: "openclaw/default",
messages: [{ role: "user", content: "hi" }],
},
headers: { "x-openclaw-agent-id": "alpha" },
matcher: /^agent:alpha:/,
matcher: /^agent:main:/,
});
}
@@ -257,10 +256,16 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "openai/gpt-5.4",
messages: [{ role: "user", content: "hi" }],
});
const res = await postChatCompletions(
port,
{
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
},
{
"x-openclaw-model": "openai/gpt-5.4",
},
);
expect(res.status).toBe(200);
const opts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0];
expect((opts as { model?: string } | undefined)?.model).toBe("openai/gpt-5.4");
@@ -279,10 +284,16 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
},
});
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "gpt-5.4",
messages: [{ role: "user", content: "hi" }],
});
const res = await postChatCompletions(
port,
{
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
},
{
"x-openclaw-model": "gpt-5.4",
},
);
expect(res.status).toBe(200);
const opts = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0];
expect((opts as { model?: string } | undefined)?.model).toBe("gpt-5.4");
@@ -299,7 +310,26 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message).toBe("Invalid `model`.");
expect(json.error?.message).toBe(
"Invalid `model`. Use `openclaw` or `openclaw/<agentId>`.",
);
expect(agentCommand).toHaveBeenCalledTimes(0);
}
{
agentCommand.mockClear();
const res = await postChatCompletions(
port,
{
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
},
{ "x-openclaw-model": "openai/" },
);
expect(res.status).toBe(400);
const json = (await res.json()) as { error?: { type?: string; message?: string } };
expect(json.error?.type).toBe("invalid_request_error");
expect(json.error?.message).toBe("Invalid `x-openclaw-model`.");
expect(agentCommand).toHaveBeenCalledTimes(0);
}

View File

@@ -443,6 +443,7 @@ export async function handleOpenAiHttpRequest(
useMessageChannelHeader: true,
});
const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({
req,
agentId,
model,
});

View File

@@ -240,7 +240,9 @@ describe("OpenResponses HTTP API (e2e)", () => {
error?: { type?: string; message?: string };
};
expect(invalidModelJson.error?.type).toBe("invalid_request_error");
expect(invalidModelJson.error?.message).toBe("Invalid `model`.");
expect(invalidModelJson.error?.message).toBe(
"Invalid `model`. Use `openclaw` or `openclaw/<agentId>`.",
);
expect(agentCommand).toHaveBeenCalledTimes(0);
await ensureResponseConsumed(resInvalidModel);
@@ -261,7 +263,7 @@ describe("OpenResponses HTTP API (e2e)", () => {
await ensureResponseConsumed(resHeader);
mockAgentOnce([{ text: "hello" }]);
const resModel = await postResponses(port, { model: "openclaw:beta", input: "hi" });
const resModel = await postResponses(port, { model: "openclaw/beta", input: "hi" });
expect(resModel.status).toBe(200);
const optsModel = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0];
expect((optsModel as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
@@ -269,6 +271,15 @@ describe("OpenResponses HTTP API (e2e)", () => {
);
await ensureResponseConsumed(resModel);
mockAgentOnce([{ text: "hello" }]);
const resDefaultAlias = await postResponses(port, { model: "openclaw/default", input: "hi" });
expect(resDefaultAlias.status).toBe(200);
const optsDefaultAlias = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0];
expect((optsDefaultAlias as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:main:/,
);
await ensureResponseConsumed(resDefaultAlias);
mockAgentOnce([{ text: "hello" }]);
const resChannelHeader = await postResponses(
port,
@@ -283,17 +294,34 @@ describe("OpenResponses HTTP API (e2e)", () => {
await ensureResponseConsumed(resChannelHeader);
mockAgentOnce([{ text: "hello" }]);
const resModelOverride = await postResponses(port, {
model: "openai/text-embedding-3-small",
input: "hi",
});
const resModelOverride = await postResponses(
port,
{
model: "openclaw",
input: "hi",
},
{ "x-openclaw-model": "openai/gpt-5.4" },
);
expect(resModelOverride.status).toBe(200);
const optsModelOverride = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0];
expect((optsModelOverride as { model?: string } | undefined)?.model).toBe(
"openai/text-embedding-3-small",
);
expect((optsModelOverride as { model?: string } | undefined)?.model).toBe("openai/gpt-5.4");
await ensureResponseConsumed(resModelOverride);
agentCommand.mockClear();
const resInvalidOverride = await postResponses(
port,
{ model: "openclaw", input: "hi" },
{ "x-openclaw-model": "openai/" },
);
expect(resInvalidOverride.status).toBe(400);
const invalidOverrideJson = (await resInvalidOverride.json()) as {
error?: { type?: string; message?: string };
};
expect(invalidOverrideJson.error?.type).toBe("invalid_request_error");
expect(invalidOverrideJson.error?.message).toBe("Invalid `x-openclaw-model`.");
expect(agentCommand).toHaveBeenCalledTimes(0);
await ensureResponseConsumed(resInvalidOverride);
mockAgentOnce([{ text: "hello" }]);
const resUser = await postResponses(port, {
user: "alice",

View File

@@ -482,6 +482,7 @@ export async function handleOpenResponsesHttpRequest(
const user = payload.user;
const agentId = resolveAgentIdForRequest({ req, model });
const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({
req,
agentId,
model,
});