fix: ensure maxTokens for anthropic-messages providers in openclaw.json (#1042)

This commit is contained in:
paisley
2026-05-19 14:48:58 +08:00
committed by GitHub
parent 3de1fed446
commit 0d5323360c
4 changed files with 432 additions and 2 deletions

View File

@@ -5,6 +5,7 @@ import type { ProviderConfig } from '../../utils/secure-storage';
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
import {
ensureAnthropicMessagesModelMaxTokens,
ensureOpenClawProviderAgentRuntimePins,
pruneInvalidApiProviderEntries,
removeProviderFromOpenClaw,
@@ -626,6 +627,17 @@ export async function syncDefaultProviderToRuntime(
logger.warn('[provider-runtime] Failed to pin embedded agent runtime for provider entries before switch:', err);
}
try {
const healed = await ensureAnthropicMessagesModelMaxTokens();
if (healed.length > 0) {
logger.warn(
`[provider-runtime] Ensured anthropic-messages maxTokens for models.providers entries before switch: ${healed.join(', ')}`,
);
}
} catch (err) {
logger.warn('[provider-runtime] Failed to ensure anthropic-messages maxTokens before switch:', err);
}
const ock = await resolveRuntimeProviderKey(provider);
const providerKey = await getApiKey(providerId);
const fallbackModels = await getProviderFallbackModelRefs(provider);

View File

@@ -244,6 +244,17 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_API_KEY',
models: [
{
id: 'MiniMax-M2.7',
name: 'MiniMax M2.7',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 204800,
maxTokens: 131072,
},
],
},
},
{
@@ -268,6 +279,17 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
baseUrl: 'https://api.minimaxi.com/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_CN_API_KEY',
models: [
{
id: 'MiniMax-M2.7',
name: 'MiniMax M2.7',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 204800,
maxTokens: 131072,
},
],
},
},
{

View File

@@ -1235,6 +1235,182 @@ function mergeProviderModels(
return merged;
}
/**
* OpenClaw 2026.5+ requires a positive `maxTokens` on each model (and can
* fall back to provider-level `maxTokens`) when `api` is `anthropic-messages`.
* ClawX-written entries historically only included `{ id, name }`.
*
* Generic Anthropic-compatible providers should not be capped at 8k by
* default: OpenClaw's native Anthropic transport caps default requests at 32k
* (`min(model.maxTokens, 32000)`), while high-output providers such as MiniMax
* M2.7 advertise a larger catalog limit.
*/
export const ANTHROPIC_MESSAGES_DEFAULT_MAX_TOKENS = 32768;
export const MINIMAX_M27_MAX_TOKENS = 131072;
function resolvePositiveMaxTokens(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined;
}
const floored = Math.floor(value);
return floored > 0 ? floored : undefined;
}
function isMiniMaxM27AnthropicEntry(
providerKey: string | undefined,
entry: Record<string, unknown> | undefined,
model: Record<string, unknown> | undefined,
): boolean {
const normalizedProvider = (providerKey || '').toLowerCase();
if (normalizedProvider === 'minimax' || normalizedProvider.startsWith('minimax-portal')) {
return true;
}
const baseUrl = typeof entry?.baseUrl === 'string' ? entry.baseUrl.toLowerCase() : '';
if (baseUrl.includes('api.minimax.io') || baseUrl.includes('api.minimaxi.com')) {
return true;
}
const modelId = typeof model?.id === 'string' ? model.id.toLowerCase() : '';
return modelId === 'minimax-m2.7' || modelId === 'minimax-m2.7-highspeed';
}
function resolveAnthropicMessagesDefaultMaxTokens(
providerKey?: string,
entry?: Record<string, unknown>,
model?: Record<string, unknown>,
): number {
if (isMiniMaxM27AnthropicEntry(providerKey, entry, model)) {
return MINIMAX_M27_MAX_TOKENS;
}
return ANTHROPIC_MESSAGES_DEFAULT_MAX_TOKENS;
}
function ensureAnthropicMessagesModelEntry(
model: Record<string, unknown>,
providerKey?: string,
entry?: Record<string, unknown>,
): Record<string, unknown> {
const resolved = resolvePositiveMaxTokens(model.maxTokens);
if (resolved !== undefined) {
if (model.maxTokens === resolved) {
return model;
}
return { ...model, maxTokens: resolved };
}
return { ...model, maxTokens: resolveAnthropicMessagesDefaultMaxTokens(providerKey, entry, model) };
}
function resolveAnthropicMessagesProviderDefaultMaxTokens(
providerKey: string | undefined,
entry: Record<string, unknown>,
): number {
if (Array.isArray(entry.models)) {
const modelDefaults = entry.models
.filter(isPlainRecord)
.map((model) => resolveAnthropicMessagesDefaultMaxTokens(providerKey, entry, model));
if (modelDefaults.length > 0) {
return Math.max(...modelDefaults);
}
}
return resolveAnthropicMessagesDefaultMaxTokens(providerKey, entry);
}
/**
* Ensure `models.providers.*` entries using `anthropic-messages` include the
* token limits OpenClaw's transport layer requires. Returns whether `entry`
* was modified.
*/
function ensureAnthropicMessagesProviderDefaults(
entry: Record<string, unknown>,
providerKey?: string,
): boolean {
if (entry.api !== 'anthropic-messages') {
return false;
}
let modified = false;
if (resolvePositiveMaxTokens(entry.maxTokens) === undefined) {
entry.maxTokens = resolveAnthropicMessagesProviderDefaultMaxTokens(providerKey, entry);
modified = true;
}
if (Array.isArray(entry.models)) {
const nextModels = (entry.models as Array<Record<string, unknown>>).map((model) => {
if (!isPlainRecord(model)) {
return model;
}
const next = ensureAnthropicMessagesModelEntry(model, providerKey, entry);
if (next !== model) {
modified = true;
}
return next;
});
entry.models = nextModels;
}
return modified;
}
function healAnthropicMessagesMaxTokensInConfig(config: Record<string, unknown>): boolean {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
for (const [providerKey, entry] of Object.entries(providers)) {
if (!isPlainRecord(entry)) {
continue;
}
if (ensureAnthropicMessagesProviderDefaults(entry, providerKey)) {
providers[providerKey] = entry;
modified = true;
console.log(
`[openclaw-auth] Ensured anthropic-messages maxTokens defaults for models.providers.${providerKey}`,
);
}
}
if (modified) {
models.providers = providers;
config.models = models;
}
return modified;
}
/**
* Self-heal helper: walk `models.providers.*` and ensure every
* `anthropic-messages` entry (and its model rows) has a positive `maxTokens`.
*/
export async function ensureAnthropicMessagesModelMaxTokens(): Promise<string[]> {
const healed: string[] = [];
await withConfigLock(async () => {
const config = await readOpenClawJson();
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
for (const [providerKey, entry] of Object.entries(providers)) {
if (!isPlainRecord(entry)) {
continue;
}
if (ensureAnthropicMessagesProviderDefaults(entry, providerKey)) {
providers[providerKey] = entry;
healed.push(providerKey);
modified = true;
}
}
if (modified) {
models.providers = providers;
config.models = models;
await writeOpenClawJson(config);
}
});
return healed;
}
/**
* Map of OpenClaw `models.providers.*` keys that must be pinned to a specific
* embedded agent harness so that OpenClaw's auto-routing policy does not
@@ -1294,13 +1470,20 @@ function upsertOpenClawProviderEntry(
? ((getProviderConfig(provider)?.models ?? []).map((m) => ({ ...m })) as Array<Record<string, unknown>>)
: [];
const runtimeModels = (options.modelIds ?? []).map((id) => ({ id, name: id }));
let mergedModels = mergeProviderModels(registryModels, existingModels, runtimeModels);
if (options.api === 'anthropic-messages') {
mergedModels = mergedModels.map((model) => ensureAnthropicMessagesModelEntry(model, provider, existingProvider));
}
const nextProvider: Record<string, unknown> = {
...existingProvider,
baseUrl: options.baseUrl,
api: options.api,
models: mergeProviderModels(registryModels, existingModels, runtimeModels),
models: mergedModels,
};
if (options.api === 'anthropic-messages') {
ensureAnthropicMessagesProviderDefaults(nextProvider, provider);
}
if (options.apiKeyEnv) nextProvider.apiKey = options.apiKeyEnv;
if (options.headers !== undefined) {
if (Object.keys(options.headers).length > 0) {
@@ -1883,7 +2066,13 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
type AgentModelProviderEntry = {
baseUrl?: string;
api?: string;
models?: Array<{ id: string; name: string; cost?: PiAiModelCostRates }>;
models?: Array<{
id: string;
name: string;
cost?: PiAiModelCostRates;
maxTokens?: number;
[key: string]: unknown;
}>;
apiKey?: string;
/** When true, pi-ai sends Authorization: Bearer instead of x-api-key */
authHeader?: boolean;
@@ -1930,6 +2119,7 @@ async function updateModelsJsonProviderEntriesForAgents(
if (mergedModels.length > 0) existing.models = mergedModels;
if (entry.apiKey !== undefined) existing.apiKey = entry.apiKey;
if (entry.authHeader !== undefined) existing.authHeader = entry.authHeader;
ensureAnthropicMessagesProviderDefaults(existing, providerType);
providers[providerType] = existing;
data.providers = providers;
@@ -2652,6 +2842,10 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
}
if (healAnthropicMessagesMaxTokensInConfig(config)) {
modified = true;
}
if (modified) {
await writeOpenClawJson(config);
console.log('[sanitize] openclaw.json sanitized successfully');

View File

@@ -1330,6 +1330,208 @@ describe('assertValidApiProtocol guard at write sites', () => {
});
});
describe('anthropic-messages maxTokens', () => {
beforeEach(async () => {
vi.resetModules();
vi.restoreAllMocks();
await rm(testHome, { recursive: true, force: true });
await rm(testUserData, { recursive: true, force: true });
});
it('adds maxTokens when syncProviderConfigToOpenClaw writes anthropic-messages providers', async () => {
await writeOpenClawJson({ models: { providers: {} } });
const { syncProviderConfigToOpenClaw, MINIMAX_M27_MAX_TOKENS } = await import('@electron/utils/openclaw-auth');
await syncProviderConfigToOpenClaw('minimax-portal', 'MiniMax-M2.7', {
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'minimax-oauth',
});
const result = await readOpenClawJson();
const provider = (result.models as Record<string, unknown>).providers as Record<string, unknown>;
const entry = provider['minimax-portal'] as Record<string, unknown>;
const models = entry.models as Array<Record<string, unknown>>;
expect(entry.maxTokens).toBe(MINIMAX_M27_MAX_TOKENS);
expect(models).toHaveLength(1);
expect(models[0]?.maxTokens).toBe(MINIMAX_M27_MAX_TOKENS);
});
it('adds maxTokens for custom providers using anthropic-messages', async () => {
await writeOpenClawJson({ models: { providers: {} } });
const { syncProviderConfigToOpenClaw, ANTHROPIC_MESSAGES_DEFAULT_MAX_TOKENS } = await import('@electron/utils/openclaw-auth');
await syncProviderConfigToOpenClaw('custom-a1b2c3d4', 'my-claude-proxy', {
baseUrl: 'https://example.com/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'CUSTOM_API_KEY',
});
const result = await readOpenClawJson();
const provider = (result.models as Record<string, unknown>).providers as Record<string, unknown>;
const entry = provider['custom-a1b2c3d4'] as Record<string, unknown>;
const models = entry.models as Array<Record<string, unknown>>;
expect(entry.maxTokens).toBe(ANTHROPIC_MESSAGES_DEFAULT_MAX_TOKENS);
expect(models[0]?.maxTokens).toBe(ANTHROPIC_MESSAGES_DEFAULT_MAX_TOKENS);
});
it('does not inject maxTokens for openai-completions providers', async () => {
await writeOpenClawJson({ models: { providers: {} } });
const { syncProviderConfigToOpenClaw } = await import('@electron/utils/openclaw-auth');
await syncProviderConfigToOpenClaw('custom-a1b2c3d4', 'gpt-proxy', {
baseUrl: 'https://example.com/v1',
api: 'openai-completions',
apiKeyEnv: 'CUSTOM_API_KEY',
});
const result = await readOpenClawJson();
const provider = (result.models as Record<string, unknown>).providers as Record<string, unknown>;
const entry = provider['custom-a1b2c3d4'] as Record<string, unknown>;
const models = entry.models as Array<Record<string, unknown>>;
expect(entry.maxTokens).toBeUndefined();
expect(models[0]?.maxTokens).toBeUndefined();
});
it('heals legacy anthropic-messages entries missing maxTokens', async () => {
await writeOpenClawJson({
models: {
providers: {
'minimax-portal': {
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
models: [{ id: 'MiniMax-M2.7', name: 'MiniMax-M2.7' }],
},
},
},
});
const { ensureAnthropicMessagesModelMaxTokens, MINIMAX_M27_MAX_TOKENS } = await import('@electron/utils/openclaw-auth');
const healed = await ensureAnthropicMessagesModelMaxTokens();
expect(healed).toEqual(['minimax-portal']);
const result = await readOpenClawJson();
const entry = ((result.models as Record<string, unknown>).providers as Record<string, unknown>)['minimax-portal'] as Record<string, unknown>;
const models = entry.models as Array<Record<string, unknown>>;
expect(entry.maxTokens).toBe(MINIMAX_M27_MAX_TOKENS);
expect(models[0]?.maxTokens).toBe(MINIMAX_M27_MAX_TOKENS);
});
it('preserves a valid user-configured maxTokens value', async () => {
await writeOpenClawJson({
models: {
providers: {
'custom-a1b2c3d4': {
baseUrl: 'https://example.com/anthropic',
api: 'anthropic-messages',
maxTokens: 4096,
models: [{ id: 'claude-proxy', name: 'claude-proxy', maxTokens: 12288 }],
},
},
},
});
const { ensureAnthropicMessagesModelMaxTokens } = await import('@electron/utils/openclaw-auth');
const healed = await ensureAnthropicMessagesModelMaxTokens();
expect(healed).toEqual([]);
const result = await readOpenClawJson();
const entry = ((result.models as Record<string, unknown>).providers as Record<string, unknown>)['custom-a1b2c3d4'] as Record<string, unknown>;
const models = entry.models as Array<Record<string, unknown>>;
expect(entry.maxTokens).toBe(4096);
expect(models[0]?.maxTokens).toBe(12288);
});
it('repairs invalid zero maxTokens during sanitizeOpenClawConfig', async () => {
await writeOpenClawJson({
models: {
providers: {
'minimax-portal': {
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
models: [{ id: 'MiniMax-M2.7', name: 'MiniMax-M2.7', maxTokens: 0 }],
},
},
},
});
const { sanitizeOpenClawConfig, MINIMAX_M27_MAX_TOKENS } = await import('@electron/utils/openclaw-auth');
await sanitizeOpenClawConfig();
const result = await readOpenClawJson();
const entry = ((result.models as Record<string, unknown>).providers as Record<string, unknown>)['minimax-portal'] as Record<string, unknown>;
const models = entry.models as Array<Record<string, unknown>>;
expect(entry.maxTokens).toBe(MINIMAX_M27_MAX_TOKENS);
expect(models[0]?.maxTokens).toBe(MINIMAX_M27_MAX_TOKENS);
});
it('adds maxTokens to agent models.json for anthropic-messages providers', async () => {
await writeOpenClawJson({ agents: { list: [{ id: 'main', name: 'Main' }] } });
const { updateAgentModelProvider, MINIMAX_M27_MAX_TOKENS } = await import('@electron/utils/openclaw-auth');
await updateAgentModelProvider('minimax-portal', {
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
authHeader: true,
apiKey: 'minimax-oauth',
models: [{ id: 'MiniMax-M2.7', name: 'MiniMax-M2.7', cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }],
});
const content = await readFile(join(testHome, '.openclaw', 'agents', 'main', 'agent', 'models.json'), 'utf8');
const result = JSON.parse(content) as Record<string, unknown>;
const providers = result.providers as Record<string, Record<string, unknown>>;
const entry = providers['minimax-portal'];
const models = entry.models as Array<Record<string, unknown>>;
expect(entry.maxTokens).toBe(MINIMAX_M27_MAX_TOKENS);
expect(models[0]?.maxTokens).toBe(MINIMAX_M27_MAX_TOKENS);
expect(models[0]?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
});
it('repairs legacy agent models.json anthropic-messages entries during update', async () => {
await writeOpenClawJson({ agents: { list: [{ id: 'main', name: 'Main' }] } });
const agentDir = join(testHome, '.openclaw', 'agents', 'main', 'agent');
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, 'models.json'), JSON.stringify({
providers: {
'minimax-portal': {
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
models: [{ id: 'MiniMax-M2.7', name: 'MiniMax-M2.7', maxTokens: 0 }],
},
},
}, null, 2), 'utf8');
const { updateAgentModelProvider, MINIMAX_M27_MAX_TOKENS } = await import('@electron/utils/openclaw-auth');
await updateAgentModelProvider('minimax-portal', {
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
models: [{ id: 'MiniMax-M2.7', name: 'MiniMax-M2.7', cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }],
});
const content = await readFile(join(agentDir, 'models.json'), 'utf8');
const result = JSON.parse(content) as Record<string, unknown>;
const entry = ((result.providers as Record<string, unknown>)['minimax-portal']) as Record<string, unknown>;
const models = entry.models as Array<Record<string, unknown>>;
expect(entry.maxTokens).toBe(MINIMAX_M27_MAX_TOKENS);
expect(models[0]?.maxTokens).toBe(MINIMAX_M27_MAX_TOKENS);
});
});
describe('pruneInvalidApiProviderEntries', () => {
beforeEach(async () => {
vi.resetModules();