Skip to content
Merged
51 changes: 47 additions & 4 deletions agents/hermes/config/model-specific-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type ModelSetupManifest = {
description: string;
match: {
modelIds?: string[];
modelIdPrefixes?: string[];
providerKey?: string;
inferenceApi?: string;
baseUrl?: string;
Expand Down Expand Up @@ -131,7 +132,13 @@ function validateMatch(match: Record<string, unknown>, manifestPath: string): vo
throw new Error(`${manifestPath}: field 'match' must be a non-empty object`);
}

const allowedKeys = new Set(["modelIds", "providerKey", "inferenceApi", "baseUrl"]);
const allowedKeys = new Set([
"modelIds",
"modelIdPrefixes",
"providerKey",
"inferenceApi",
"baseUrl",
]);
const unknownKeys = Object.keys(match).filter((key) => !allowedKeys.has(key));
if (unknownKeys.length > 0) {
throw new Error(`${manifestPath}: unknown match keys: ${unknownKeys.join(", ")}`);
Expand All @@ -145,6 +152,27 @@ function validateMatch(match: Record<string, unknown>, manifestPath: string): vo
) {
throw new Error(`${manifestPath}: match.modelIds must be a non-empty string array`);
}
if (
match.modelIdPrefixes !== undefined &&
(!Array.isArray(match.modelIdPrefixes) ||
match.modelIdPrefixes.length === 0 ||
!match.modelIdPrefixes.every(isNonEmptyString))
) {
throw new Error(`${manifestPath}: match.modelIdPrefixes must be a non-empty string array`);
}
if (
Array.isArray(match.modelIdPrefixes) &&
match.modelIdPrefixes.some((prefix) => String(prefix).includes("/"))
) {
throw new Error(
`${manifestPath}: match.modelIdPrefixes must contain bare model ids without namespaces`,
);
}
if (match.modelIds !== undefined && match.modelIdPrefixes !== undefined) {
throw new Error(
`${manifestPath}: match.modelIds and match.modelIdPrefixes are mutually exclusive`,
);
}
for (const key of ["providerKey", "inferenceApi", "baseUrl"]) {
const value = match[key];
if (value !== undefined && !isNonEmptyString(value)) {
Expand Down Expand Up @@ -176,11 +204,26 @@ function validateSelectedAgentEffects(payload: ModelSetupManifest, manifestPath:

function modelSetupMatches(payload: ModelSetupManifest, context: ModelSetupContext): boolean {
const match = payload.match;
const normalizedModel = context.model.trim().toLowerCase();
if (
match.modelIds &&
!new Set(match.modelIds.map((modelId) => modelId.trim().toLowerCase())).has(
context.model.trim().toLowerCase(),
)
!new Set(match.modelIds.map((modelId) => modelId.trim().toLowerCase())).has(normalizedModel)
) {
return false;
}
const bareModel = normalizedModel.includes("/")
? normalizedModel.slice(normalizedModel.lastIndexOf("/") + 1)
: normalizedModel;
if (
match.modelIdPrefixes &&
!match.modelIdPrefixes.some((value) => {
const prefix = value.trim().toLowerCase();
return (
bareModel === prefix ||
bareModel.startsWith(`${prefix}.`) ||
bareModel.startsWith(`${prefix}-`)
);
})
) {
return false;
}
Expand Down
7 changes: 7 additions & 0 deletions docs/inference/choose-compatible-inference-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ A successful Responses probe does not change the runtime API by itself.
Without an explicit preference, the sandbox still uses `/v1/chat/completions`.
This default avoids local backends that accept Responses requests but drop system prompts or tool definitions.

<AgentOnly variant="openclaw">

For GPT-5 and the `o1`, `o3`, and `o4` model families, NemoClaw configures OpenClaw to send the maximum reply token limit as `max_completion_tokens` instead of the legacy `max_tokens`.
This automatic compatibility handling recognizes provider-prefixed and suffixed model IDs, such as `azure/gpt-5.4`, `gpt-5.4-turbo`, and `openai/o3-mini`.

</AgentOnly>

When a reasoning model returns only reasoning content before a final answer, NemoClaw retries the smoke request with a larger response budget.
Route, configuration, and authentication failures still fail immediately.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "../schema.json",
"id": "gpt-5-o-series-managed-inference",
"agent": "openclaw",
"description": "Routes OpenClaw's reply budget to max_completion_tokens for GPT-5 and o1/o3/o4 model families on the managed inference.local chat completions route.",
"match": {
"modelIdPrefixes": ["gpt-5", "o1", "o3", "o4"],
"providerKey": "inference",
"inferenceApi": "openai-completions",
"baseUrl": "https://inference.local/v1"
},
"effects": {
"openclawCompat": {
"maxTokensField": "max_completion_tokens"
}
}
}
20 changes: 20 additions & 0 deletions nemoclaw-blueprint/model-specific-setup/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@
"minItems": 1,
"uniqueItems": true
},
"modelIdPrefixes": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"allOf": [
{
"pattern": ".*\\S.*"
},
{
"pattern": "^[^/]+$"
}
]
},
"minItems": 1,
"uniqueItems": true
},
"providerKey": {
"type": "string",
"minLength": 1,
Expand All @@ -55,6 +72,9 @@
"minLength": 1,
"pattern": ".*\\S.*"
}
},
"not": {
"required": ["modelIds", "modelIdPrefixes"]
}
},
"effects": {
Expand Down
54 changes: 50 additions & 4 deletions scripts/generate-openclaw-config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,13 @@ function validateManifestPayload(payload: unknown, manifestPath: string): JsonOb
if (Object.keys(match).length === 0) {
throw new Error(`${manifestPath}: field 'match' must be a non-empty object`);
}
const allowedMatchKeys = new Set(["modelIds", "providerKey", "inferenceApi", "baseUrl"]);
const allowedMatchKeys = new Set([
"modelIds",
"modelIdPrefixes",
"providerKey",
"inferenceApi",
"baseUrl",
]);
const unknownMatchKeys = Object.keys(match)
.filter((key) => !allowedMatchKeys.has(key))
.sort();
Expand All @@ -376,6 +382,28 @@ function validateManifestPayload(payload: unknown, manifestPath: string): JsonOb
) {
throw new Error(`${manifestPath}: match.modelIds must be a non-empty string array`);
}
const modelIdPrefixes = match.modelIdPrefixes;
if (
modelIdPrefixes !== undefined &&
(!Array.isArray(modelIdPrefixes) ||
modelIdPrefixes.length === 0 ||
!modelIdPrefixes.every((prefix) => typeof prefix === "string" && prefix.trim()))
) {
throw new Error(`${manifestPath}: match.modelIdPrefixes must be a non-empty string array`);
}
if (
Array.isArray(modelIdPrefixes) &&
modelIdPrefixes.some((prefix) => String(prefix).includes("/"))
) {
throw new Error(
`${manifestPath}: match.modelIdPrefixes must contain bare model ids without namespaces`,
);
}
if (modelIds !== undefined && modelIdPrefixes !== undefined) {
throw new Error(
`${manifestPath}: match.modelIds and match.modelIdPrefixes are mutually exclusive`,
);
}
for (const key of ["providerKey", "inferenceApi", "baseUrl"]) {
const value = match[key];
if (value !== undefined && (typeof value !== "string" || !value.trim())) {
Expand Down Expand Up @@ -489,13 +517,31 @@ function validateSelectedAgentEffects(

function modelSetupMatches(payload: JsonObject, context: JsonObject): boolean {
const match = payload.match;
const normalizedModel = String(context.model).trim().toLowerCase();
const modelIds = match.modelIds;
if (
Array.isArray(modelIds) &&
modelIds.length > 0 &&
!new Set(modelIds.map((modelId) => String(modelId).trim().toLowerCase())).has(
String(context.model).trim().toLowerCase(),
)
!new Set(modelIds.map((modelId) => String(modelId).trim().toLowerCase())).has(normalizedModel)
) {
return false;
}

const modelIdPrefixes = match.modelIdPrefixes;
const bareModel = normalizedModel.includes("/")
? normalizedModel.slice(normalizedModel.lastIndexOf("/") + 1)
: normalizedModel;
if (
Array.isArray(modelIdPrefixes) &&
modelIdPrefixes.length > 0 &&
!modelIdPrefixes.some((value) => {
const prefix = String(value).trim().toLowerCase();
return (
bareModel === prefix ||
bareModel.startsWith(`${prefix}.`) ||
bareModel.startsWith(`${prefix}-`)
);
})
) {
return false;
}
Expand Down
49 changes: 49 additions & 0 deletions src/lib/inference/max-tokens-field.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";
import { requiresMaxCompletionTokensField, resolveMaxTokensField } from "./max-tokens-field";

describe("resolveMaxTokensField", () => {
it("selects max_completion_tokens for the GPT-5 family (#6642)", () => {
for (const model of ["gpt-5", "gpt-5.4", "gpt-5.4-turbo", "GPT-5.4"]) {
expect(resolveMaxTokensField(model)).toBe("max_completion_tokens");
expect(requiresMaxCompletionTokensField(model)).toBe(true);
}
});

it("selects max_completion_tokens for OpenAI reasoning models", () => {
for (const model of ["o1", "o1-mini", "o3", "o3-mini", "o4-mini"]) {
expect(resolveMaxTokensField(model)).toBe("max_completion_tokens");
}
});

it("strips a provider prefix before matching", () => {
expect(resolveMaxTokensField("azure/gpt-5.4")).toBe("max_completion_tokens");
expect(resolveMaxTokensField("openai/o3-mini")).toBe("max_completion_tokens");
});

it("keeps max_tokens for legacy and non-OpenAI models", () => {
for (const model of [
"gpt-4o",
"gpt-4.1",
"nvidia/nemotron-3-super-120b-a12b",
"moonshotai/kimi-k2.6",
"deepseek-ai/deepseek-v4-pro",
]) {
expect(resolveMaxTokensField(model)).toBe("max_tokens");
}
});

it("does not misfire on models that merely start with the letter o", () => {
for (const model of ["openai-gpt", "orca-2", "olmo-7b"]) {
expect(resolveMaxTokensField(model)).toBe("max_tokens");
}
});

it("defaults to max_tokens for empty or nullish model ids", () => {
expect(resolveMaxTokensField("")).toBe("max_tokens");
expect(resolveMaxTokensField(null)).toBe("max_tokens");
expect(resolveMaxTokensField(undefined)).toBe("max_tokens");
});
});
43 changes: 43 additions & 0 deletions src/lib/inference/max-tokens-field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* Resolves the OpenAI-compatible Chat Completions reply-budget field name for a
* given model.
*
* OpenAI's GPT-5 family and the reasoning-model series (o1/o3/o4) reject the
* legacy `max_tokens` parameter on `/chat/completions` and require
* `max_completion_tokens` instead — Azure OpenAI surfaces the same requirement
* (HTTP 400: "Unsupported parameter: 'max_tokens' is not supported with this
* model. Use 'max_completion_tokens' instead."). Both the host-side onboarding
* probe and the in-sandbox smoke check must agree on the field name, so they
* share this single resolver rather than each carrying their own model list.
*/

// Matched by prefix rather than exact id: Azure OpenAI deployments append
// version/suffix segments (e.g. "gpt-5.4", "gpt-5.4-turbo") and callers may or
// may not include a provider prefix ("azure/gpt-5.4").
const MAX_COMPLETION_TOKENS_MODEL_PREFIXES = ["gpt-5", "o1", "o3", "o4"];

/**
* Whether the model requires `max_completion_tokens` in place of `max_tokens`.
*/
export function requiresMaxCompletionTokensField(model: string | null | undefined): boolean {
const normalized = String(model || "").toLowerCase();
const bare = normalized.includes("/")
? normalized.slice(normalized.lastIndexOf("/") + 1)
: normalized;
return MAX_COMPLETION_TOKENS_MODEL_PREFIXES.some(
(prefix) => bare === prefix || bare.startsWith(`${prefix}.`) || bare.startsWith(`${prefix}-`),
);
}

/**
* Returns the Chat Completions reply-budget field name for the model:
* `max_completion_tokens` for GPT-5/o-series, otherwise `max_tokens`.
*/
export function resolveMaxTokensField(
model: string | null | undefined,
): "max_tokens" | "max_completion_tokens" {
return requiresMaxCompletionTokensField(model) ? "max_completion_tokens" : "max_tokens";
}
18 changes: 15 additions & 3 deletions src/lib/inference/onboard-probes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,16 @@ describe("OpenAI-compatible inference probes", () => {
});
});

it("uses max_completion_tokens for GPT-5 family and reasoning models (#6642)", () => {
for (const model of ["gpt-5.4", "azure/gpt-5.4", "o3-mini", "o1"]) {
expect(getChatCompletionsProbePayload(model)).toEqual({
model,
messages: [{ role: "user", content: "Reply with exactly: OK" }],
max_completion_tokens: 8,
});
}
});

it("uses an extended validation budget for slow NVIDIA Build models", () => {
for (const model of ["qwen/qwen3.5-397b-a17b", "deepseek-ai/deepseek-v4-flash"]) {
const args = getChatCompletionsProbeCurlArgs({
Expand Down Expand Up @@ -660,7 +670,7 @@ exit 0
);
});

it("keeps timeout retries strict when chat-completions tool calling is required", () => {
it("keeps GPT-5 timeout retries strict when tool calling is required (#6642)", () => {
const script = `#!/usr/bin/env bash
outfile=""
payload=""
Expand Down Expand Up @@ -696,7 +706,7 @@ exit 0
({ counter, tmpDir }) => {
const result = probeOpenAiLikeEndpoint(
"https://api.example.com/v1",
"test-model",
"gpt-5.4",
"sk-test",
{ skipResponsesProbe: true, requireChatCompletionsToolCalling: true },
);
Expand All @@ -709,9 +719,11 @@ exit 0
);
expect(retryPayload).toMatchObject({
tool_choice: "required",
max_tokens: 256,
max_completion_tokens: 256,
stream: false,
});
expect(retryPayload.max_tokens).toBeUndefined();
expect(retryPayload.temperature).toBeUndefined();
},
);
});
Expand Down
Loading
Loading