mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
feat(gateway): make openai compatibility agent-first
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -443,6 +443,7 @@ export async function handleOpenAiHttpRequest(
|
||||
useMessageChannelHeader: true,
|
||||
});
|
||||
const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({
|
||||
req,
|
||||
agentId,
|
||||
model,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user