mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 11:43:33 +00:00
fix(doctor): merge legacy codex models safely
Merge disjoint legacy openai-codex model entries into the canonical openai provider without losing safe per-model metadata, params, or models-add markers. Unsafe provider-level defaults, auth/header/request state, and blocked normalized legacy providers are now preserved for manual cleanup with doctor preview warnings instead of being silently copied into models or repeatedly reported as auto-fixable. Fixes #90047 Co-authored-by: openperf <16864032@qq.com>
This commit is contained in:
@@ -128,6 +128,420 @@ describe("legacy memory search config migrate", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges disjoint model entries from legacy codex into canonical openai and preserves legacy baseUrl (#90047)", () => {
|
||||
const res = migrateLegacyConfigForTest({
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "text-embedding-3-small" }],
|
||||
},
|
||||
"openai-codex": {
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
models: [{ id: "gpt-5.5", api: "openai-codex-responses" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Legacy codex model must be merged with legacy provider baseUrl stamped on it
|
||||
// so it routes to https://chatgpt.com/backend-api, not https://api.openai.com/v1.
|
||||
const openai = res.config?.models?.providers?.openai as Record<string, unknown> | undefined;
|
||||
expect(openai?.models).toEqual([
|
||||
{ id: "text-embedding-3-small" },
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
},
|
||||
]);
|
||||
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
|
||||
expectMigrationChangesToIncludeFragments(res.changes, [
|
||||
'Moved models.providers.openai-codex.models[0].api "openai-codex-responses" → "openai-chatgpt-responses"',
|
||||
"Merged 1 model(s) from models.providers.openai-codex into models.providers.openai: gpt-5.5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips already-present model ids when merging legacy codex into canonical openai", () => {
|
||||
const res = migrateLegacyConfigForTest({
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5.5" }],
|
||||
},
|
||||
"openai-codex": {
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
models: [{ id: "gpt-5.5" }, { id: "gpt-5.4" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const openai = res.config?.models?.providers?.openai as Record<string, unknown> | undefined;
|
||||
// gpt-5.5 already in canonical → skip; gpt-5.4 is new → merged with legacy provider baseUrl/api
|
||||
expect((openai?.models as unknown[])?.length).toBe(2);
|
||||
expect(openai?.models).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: "gpt-5.5" },
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
|
||||
expectMigrationChangesToIncludeFragments(res.changes, [
|
||||
"Merged 1 model(s) from models.providers.openai-codex into models.providers.openai: gpt-5.4",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps merged codex models when later canonical openai normalization runs", () => {
|
||||
const res = migrateLegacyConfigForTest({
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
models: [{ id: "gpt-5.5", api: "openai-chatgpt-responses" }],
|
||||
},
|
||||
openai: {
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "text-embedding-3-small", api: "openai-codex-responses" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const openai = res.config?.models?.providers?.openai as Record<string, unknown> | undefined;
|
||||
expect(openai).toEqual({
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [
|
||||
{ id: "text-embedding-3-small", api: "openai-chatgpt-responses" },
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
|
||||
expectMigrationChangesToIncludeFragments(res.changes, [
|
||||
'Moved models.providers.openai.api "openai-codex-responses" → "openai-chatgpt-responses"',
|
||||
'Moved models.providers.openai.models[0].api "openai-codex-responses" → "openai-chatgpt-responses"',
|
||||
"Merged 1 model(s) from models.providers.openai-codex into models.providers.openai: gpt-5.5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves model-scoped legacy provider defaults when merging codex models", () => {
|
||||
const res = migrateLegacyConfigForTest({
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "text-embedding-3-small" }],
|
||||
},
|
||||
"openai-codex": {
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
contextWindow: 200000,
|
||||
contextTokens: 180000,
|
||||
maxTokens: 8192,
|
||||
params: { store: false },
|
||||
agentRuntime: { id: "codex" },
|
||||
models: [{ id: "gpt-5.5" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const openai = res.config?.models?.providers?.openai as Record<string, unknown> | undefined;
|
||||
expect(openai?.models).toEqual([
|
||||
{ id: "text-embedding-3-small" },
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
contextWindow: 200000,
|
||||
contextTokens: 180000,
|
||||
maxTokens: 8192,
|
||||
params: { store: false },
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
]);
|
||||
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
|
||||
expectMigrationChangesToIncludeFragments(res.changes, [
|
||||
"Merged 1 model(s) from models.providers.openai-codex into models.providers.openai: gpt-5.5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges legacy provider params into model params when merging codex models", () => {
|
||||
const res = migrateLegacyConfigForTest({
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "text-embedding-3-small" }],
|
||||
},
|
||||
"openai-codex": {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
params: { store: false, reasoning: { effort: "medium" } },
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
params: { reasoning: { effort: "high" }, verbosity: "low" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const openai = res.config?.models?.providers?.openai as Record<string, unknown> | undefined;
|
||||
expect(openai?.models).toEqual([
|
||||
{ id: "text-embedding-3-small" },
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
params: {
|
||||
store: false,
|
||||
reasoning: { effort: "high" },
|
||||
verbosity: "low",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves legacy models-add metadata marker when merging codex models", () => {
|
||||
const res = migrateLegacyConfigForTest({
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "text-embedding-3-small" }],
|
||||
},
|
||||
"openai-codex": {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
api: "openai-chatgpt-responses",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
|
||||
contextWindow: 400_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const openai = res.config?.models?.providers?.openai as Record<string, unknown> | undefined;
|
||||
expect(openai?.models).toEqual([
|
||||
{ id: "text-embedding-3-small" },
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
api: "openai-chatgpt-responses",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
|
||||
contextWindow: 400_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
metadataSource: "models-add",
|
||||
},
|
||||
]);
|
||||
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
|
||||
});
|
||||
|
||||
it("keeps legacy codex provider when existing openai defaults would leak into merged models", () => {
|
||||
const res = migrateLegacyConfigForTest({
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-responses",
|
||||
apiKey: "OPENAI_API_KEY",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
params: { store: true },
|
||||
request: { retry: { maxAttempts: 1 } },
|
||||
models: [{ id: "text-embedding-3-small" }],
|
||||
},
|
||||
"openai-codex": {
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
models: [{ id: "gpt-5.5", api: "openai-codex-responses" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.config?.models?.providers?.openai).toEqual({
|
||||
api: "openai-responses",
|
||||
apiKey: "OPENAI_API_KEY",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
params: { store: true },
|
||||
request: { retry: { maxAttempts: 1 } },
|
||||
models: [{ id: "text-embedding-3-small" }],
|
||||
});
|
||||
expect(res.config?.models?.providers?.["openai-codex"]).toEqual({
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
models: [{ id: "gpt-5.5", api: "openai-chatgpt-responses" }],
|
||||
});
|
||||
expectMigrationChangesToIncludeFragments(res.changes, [
|
||||
'Moved models.providers.openai-codex.api "openai-codex-responses" → "openai-chatgpt-responses"',
|
||||
'Moved models.providers.openai-codex.models[0].api "openai-codex-responses" → "openai-chatgpt-responses"',
|
||||
"Skipped merging models.providers.openai-codex into models.providers.openai because provider-level defaults cannot be represented safely on merged models: models.providers.openai.apiKey, models.providers.openai.params, models.providers.openai.request",
|
||||
]);
|
||||
expect(findLegacyConfigIssues(res.config).map((issue) => issue.path)).not.toContain(
|
||||
"models.providers",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps legacy codex provider when legacy auth or headers cannot be model-scoped", () => {
|
||||
const res = migrateLegacyConfigForTest({
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "text-embedding-3-small" }],
|
||||
},
|
||||
"openai-codex": {
|
||||
auth: "oauth",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
models: [{ id: "gpt-5.5", api: "openai-codex-responses" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.config?.models?.providers?.openai).toEqual({
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "text-embedding-3-small" }],
|
||||
});
|
||||
expect(res.config?.models?.providers?.["openai-codex"]).toEqual({
|
||||
auth: "oauth",
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
models: [{ id: "gpt-5.5", api: "openai-chatgpt-responses" }],
|
||||
});
|
||||
expectMigrationChangesToIncludeFragments(res.changes, [
|
||||
'Moved models.providers.openai-codex.api "openai-codex-responses" → "openai-chatgpt-responses"',
|
||||
'Moved models.providers.openai-codex.models[0].api "openai-codex-responses" → "openai-chatgpt-responses"',
|
||||
"Skipped merging models.providers.openai-codex into models.providers.openai because provider-level defaults cannot be represented safely on merged models: models.providers.openai-codex.auth, models.providers.openai-codex.headers",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not report a fixable legacy issue after blocked codex merge normalization already ran", () => {
|
||||
const raw = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
params: { store: true },
|
||||
models: [{ id: "text-embedding-3-small" }],
|
||||
},
|
||||
"openai-codex": {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
models: [{ id: "gpt-5.5", api: "openai-chatgpt-responses" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = migrateLegacyConfigForTest(raw);
|
||||
|
||||
expect(res.config).toBeNull();
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).not.toContain(
|
||||
"models.providers",
|
||||
);
|
||||
});
|
||||
|
||||
it("merges distinct legacy model ids even when display names collide", () => {
|
||||
const res = migrateLegacyConfigForTest({
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5.5-platform", name: "GPT-5.5" }],
|
||||
},
|
||||
"openai-codex": {
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
models: [{ id: "gpt-5.5", name: "GPT-5.5" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const openai = res.config?.models?.providers?.openai as Record<string, unknown> | undefined;
|
||||
expect(openai?.models).toEqual([
|
||||
{ id: "gpt-5.5-platform", name: "GPT-5.5" },
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
},
|
||||
]);
|
||||
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
|
||||
expectMigrationChangesToIncludeFragments(res.changes, [
|
||||
"Merged 1 model(s) from models.providers.openai-codex into models.providers.openai: gpt-5.5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes openai-codex when all its models already exist in canonical openai", () => {
|
||||
const res = migrateLegacyConfigForTest({
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5.5" }, { id: "gpt-5.4" }],
|
||||
},
|
||||
"openai-codex": {
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
models: [{ id: "gpt-5.5" }, { id: "gpt-5.4" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const openai = res.config?.models?.providers?.openai as Record<string, unknown> | undefined;
|
||||
// All legacy models are already present; canonical provider unchanged
|
||||
expect(openai?.models).toEqual([{ id: "gpt-5.5" }, { id: "gpt-5.4" }]);
|
||||
expect(res.config?.models?.providers).not.toHaveProperty("openai-codex");
|
||||
expect(res.changes).toContain(
|
||||
"Removed models.providers.openai-codex because models.providers.openai already exists.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites top-level legacy auto provider after moving memorySearch into agent defaults", () => {
|
||||
const raw = {
|
||||
memorySearch: {
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
type LegacyConfigMigrationSpec,
|
||||
type LegacyConfigRule,
|
||||
} from "../../../config/legacy.shared.js";
|
||||
import { isModelThinkingFormat } from "../../../config/types.models.js";
|
||||
import { isModelThinkingFormat, type ModelDefinitionConfig } from "../../../config/types.models.js";
|
||||
import { isLegacyModelsAddCodexMetadataModel } from "./legacy-models-add-metadata.js";
|
||||
|
||||
const STALE_CONTEXT_WINDOW_FIXES: Record<string, { stale: number; correct: number }> = {
|
||||
"deepseek/deepseek-v4-flash": { stale: 200_000, correct: 1_000_000 },
|
||||
@@ -925,6 +926,33 @@ const LEGACY_OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
||||
const LEGACY_OPENAI_CODEX_RESPONSES_API = "openai-codex-responses";
|
||||
const OPENAI_PROVIDER_ID = "openai";
|
||||
const OPENAI_CHATGPT_RESPONSES_API = "openai-chatgpt-responses";
|
||||
const MODEL_UNSCOPED_PROVIDER_DEFAULT_KEYS = [
|
||||
"apiKey",
|
||||
"auth",
|
||||
"request",
|
||||
"timeoutSeconds",
|
||||
"region",
|
||||
"injectNumCtxForOpenAICompat",
|
||||
"localService",
|
||||
"headers",
|
||||
"authHeader",
|
||||
] as const;
|
||||
const CANONICAL_PROVIDER_MODEL_LEAK_KEYS = [
|
||||
"apiKey",
|
||||
"auth",
|
||||
"contextWindow",
|
||||
"contextTokens",
|
||||
"maxTokens",
|
||||
"timeoutSeconds",
|
||||
"region",
|
||||
"injectNumCtxForOpenAICompat",
|
||||
"params",
|
||||
"agentRuntime",
|
||||
"localService",
|
||||
"headers",
|
||||
"authHeader",
|
||||
"request",
|
||||
] as const;
|
||||
|
||||
function hasCanonicalOpenAIProvider(providers: Record<string, unknown>): boolean {
|
||||
return Object.keys(providers).some(
|
||||
@@ -972,6 +1000,193 @@ function normalizeLegacyOpenAIResponsesApi(
|
||||
return { value: next, changed };
|
||||
}
|
||||
|
||||
function hasOwnDefinedProperty(record: Record<string, unknown>, key: string): boolean {
|
||||
return Object.hasOwn(record, key) && record[key] !== undefined;
|
||||
}
|
||||
|
||||
function collectModelMergeBlockers(params: {
|
||||
canonical: Record<string, unknown>;
|
||||
legacy: Record<string, unknown>;
|
||||
}): string[] {
|
||||
const blockers: string[] = [];
|
||||
for (const key of MODEL_UNSCOPED_PROVIDER_DEFAULT_KEYS) {
|
||||
if (hasOwnDefinedProperty(params.legacy, key)) {
|
||||
blockers.push(`models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID}.${key}`);
|
||||
}
|
||||
}
|
||||
for (const key of CANONICAL_PROVIDER_MODEL_LEAK_KEYS) {
|
||||
if (hasOwnDefinedProperty(params.canonical, key)) {
|
||||
blockers.push(`models.providers.${OPENAI_PROVIDER_ID}.${key}`);
|
||||
}
|
||||
}
|
||||
return blockers;
|
||||
}
|
||||
|
||||
function getCanonicalOpenAIProviderEntry(
|
||||
providers: Record<string, unknown>,
|
||||
): { key: string; value: Record<string, unknown> } | undefined {
|
||||
const key = Object.keys(providers).find((k) => normalizeProviderId(k) === OPENAI_PROVIDER_ID);
|
||||
const value = key ? getRecord(providers[key]) : undefined;
|
||||
return key && value ? { key, value } : undefined;
|
||||
}
|
||||
|
||||
function getMergeableLegacyOpenAIModels(params: {
|
||||
canonical: Record<string, unknown>;
|
||||
legacy: Record<string, unknown>;
|
||||
}): unknown[] {
|
||||
const legacyModels: unknown[] = Array.isArray(params.legacy.models)
|
||||
? (params.legacy.models as unknown[])
|
||||
: [];
|
||||
const canonicalModels: unknown[] = Array.isArray(params.canonical.models)
|
||||
? (params.canonical.models as unknown[])
|
||||
: [];
|
||||
const canonicalModelIds = new Set<string>();
|
||||
const canonicalModelNames = new Set<string>();
|
||||
for (const m of canonicalModels) {
|
||||
const mr = getRecord(m);
|
||||
if (typeof mr?.id === "string" && mr.id) {
|
||||
canonicalModelIds.add(mr.id);
|
||||
}
|
||||
if (typeof mr?.name === "string" && mr.name) {
|
||||
canonicalModelNames.add(mr.name);
|
||||
}
|
||||
}
|
||||
return legacyModels.filter((m) => {
|
||||
const mr = getRecord(m);
|
||||
if (!mr) {
|
||||
return false;
|
||||
}
|
||||
const id = typeof mr.id === "string" ? mr.id : undefined;
|
||||
const name = typeof mr.name === "string" ? mr.name : undefined;
|
||||
if (!id && !name) {
|
||||
return false;
|
||||
}
|
||||
return id ? !canonicalModelIds.has(id) : name ? !canonicalModelNames.has(name) : false;
|
||||
});
|
||||
}
|
||||
|
||||
function hasAutoFixableLegacyOpenAICodexProvider(providersValue: unknown): boolean {
|
||||
const providers = getRecord(providersValue);
|
||||
if (!providers) {
|
||||
return false;
|
||||
}
|
||||
const canonicalEntry = getCanonicalOpenAIProviderEntry(providers);
|
||||
for (const [providerId, providerValue] of Object.entries(providers)) {
|
||||
const provider = getRecord(providerValue);
|
||||
if (!provider || normalizeProviderId(providerId) !== LEGACY_OPENAI_CODEX_PROVIDER_ID) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, []);
|
||||
if (normalized.changed || !canonicalEntry) {
|
||||
return true;
|
||||
}
|
||||
const modelsToMerge = getMergeableLegacyOpenAIModels({
|
||||
canonical: canonicalEntry.value,
|
||||
legacy: normalized.value,
|
||||
});
|
||||
if (modelsToMerge.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const mergeBlockers = collectModelMergeBlockers({
|
||||
canonical: canonicalEntry.value,
|
||||
legacy: normalized.value,
|
||||
});
|
||||
if (mergeBlockers.length === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function collectBlockedLegacyOpenAICodexProviderWarnings(raw: unknown): string[] {
|
||||
const models = getRecord(getRecord(raw)?.models);
|
||||
const providers = getRecord(models?.providers);
|
||||
const canonicalEntry = providers ? getCanonicalOpenAIProviderEntry(providers) : undefined;
|
||||
if (!providers || !canonicalEntry) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
for (const [providerId, providerValue] of Object.entries(providers)) {
|
||||
const provider = getRecord(providerValue);
|
||||
if (!provider || normalizeProviderId(providerId) !== LEGACY_OPENAI_CODEX_PROVIDER_ID) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, []);
|
||||
if (normalized.changed) {
|
||||
continue;
|
||||
}
|
||||
const modelsToMerge = getMergeableLegacyOpenAIModels({
|
||||
canonical: canonicalEntry.value,
|
||||
legacy: normalized.value,
|
||||
});
|
||||
if (modelsToMerge.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const mergeBlockers = collectModelMergeBlockers({
|
||||
canonical: canonicalEntry.value,
|
||||
legacy: normalized.value,
|
||||
});
|
||||
if (mergeBlockers.length === 0) {
|
||||
continue;
|
||||
}
|
||||
warnings.push(
|
||||
`models.providers.${providerId} cannot be merged automatically into models.providers.${canonicalEntry.key} because provider-level defaults cannot be represented safely on merged models: ${mergeBlockers.join(", ")}. Move the affected model/provider defaults manually before removing models.providers.${providerId}.`,
|
||||
);
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function buildMergedLegacyOpenAIModel(
|
||||
model: unknown,
|
||||
legacyProvider: Record<string, unknown>,
|
||||
): unknown {
|
||||
const modelRecord = getRecord(model);
|
||||
if (!modelRecord) {
|
||||
return model;
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
const legacyBaseUrl =
|
||||
typeof legacyProvider.baseUrl === "string" ? legacyProvider.baseUrl : undefined;
|
||||
const legacyApi = typeof legacyProvider.api === "string" ? legacyProvider.api : undefined;
|
||||
const legacyParams = getRecord(legacyProvider.params);
|
||||
const legacyAgentRuntime = getRecord(legacyProvider.agentRuntime);
|
||||
|
||||
if (legacyBaseUrl && !modelRecord.baseUrl) {
|
||||
patch.baseUrl = legacyBaseUrl;
|
||||
}
|
||||
if (legacyApi && !modelRecord.api) {
|
||||
patch.api = legacyApi;
|
||||
}
|
||||
for (const key of ["contextWindow", "contextTokens", "maxTokens"] as const) {
|
||||
if (typeof legacyProvider[key] === "number" && modelRecord[key] === undefined) {
|
||||
patch[key] = legacyProvider[key];
|
||||
}
|
||||
}
|
||||
if (legacyParams) {
|
||||
const modelParams = getRecord(modelRecord.params);
|
||||
if (modelParams) {
|
||||
patch.params = { ...legacyParams, ...modelParams };
|
||||
} else if (modelRecord.params === undefined) {
|
||||
patch.params = legacyParams;
|
||||
}
|
||||
}
|
||||
if (legacyAgentRuntime && modelRecord.agentRuntime === undefined) {
|
||||
patch.agentRuntime = legacyAgentRuntime;
|
||||
}
|
||||
if (
|
||||
modelRecord.metadataSource === undefined &&
|
||||
isLegacyModelsAddCodexMetadataModel({
|
||||
provider: LEGACY_OPENAI_CODEX_PROVIDER_ID,
|
||||
model: modelRecord as Partial<ModelDefinitionConfig>,
|
||||
})
|
||||
) {
|
||||
patch.metadataSource = "models-add";
|
||||
}
|
||||
return Object.keys(patch).length > 0 ? Object.assign({}, modelRecord, patch) : model;
|
||||
}
|
||||
|
||||
function migrateLegacyOpenAICodexProvider(raw: Record<string, unknown>, changes: string[]): void {
|
||||
const models = getRecord(raw.models);
|
||||
const providers = getRecord(models?.providers);
|
||||
@@ -981,7 +1196,7 @@ function migrateLegacyOpenAICodexProvider(raw: Record<string, unknown>, changes:
|
||||
|
||||
let providersChanged = false;
|
||||
for (const [providerId, providerValue] of Object.entries({ ...providers })) {
|
||||
const provider = getRecord(providerValue);
|
||||
const provider = getRecord(providers[providerId]) ?? getRecord(providerValue);
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
@@ -1001,9 +1216,58 @@ function migrateLegacyOpenAICodexProvider(raw: Record<string, unknown>, changes:
|
||||
`Moved models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} → models.providers.${OPENAI_PROVIDER_ID}.`,
|
||||
);
|
||||
} else {
|
||||
changes.push(
|
||||
`Removed models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} because models.providers.${OPENAI_PROVIDER_ID} already exists.`,
|
||||
);
|
||||
// Canonical openai provider already exists. Merge non-conflicting model
|
||||
// entries from the legacy provider so disjoint models (e.g. a chat model
|
||||
// on the Codex OAuth path alongside an embeddings-only openai provider)
|
||||
// are preserved instead of silently dropped. (#90047)
|
||||
const canonicalEntry = getCanonicalOpenAIProviderEntry(providers);
|
||||
const canonicalKey = canonicalEntry?.key ?? OPENAI_PROVIDER_ID;
|
||||
const canonical = canonicalEntry?.value ?? {};
|
||||
const canonicalModels: unknown[] = Array.isArray(canonical.models)
|
||||
? (canonical.models as unknown[])
|
||||
: [];
|
||||
const modelsToMerge = getMergeableLegacyOpenAIModels({
|
||||
canonical,
|
||||
legacy: normalized.value,
|
||||
});
|
||||
const mergeBlockers =
|
||||
modelsToMerge.length > 0
|
||||
? collectModelMergeBlockers({ canonical, legacy: normalized.value })
|
||||
: [];
|
||||
if (mergeBlockers.length > 0) {
|
||||
if (normalized.changed) {
|
||||
providers[providerId] = normalized.value;
|
||||
providersChanged = true;
|
||||
changes.push(
|
||||
`Skipped merging models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} into models.providers.${OPENAI_PROVIDER_ID} because provider-level defaults cannot be represented safely on merged models: ${mergeBlockers.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Stamp model-scoped legacy provider defaults onto each merged model so it
|
||||
// keeps the Codex endpoint and runtime metadata instead of inheriting the
|
||||
// canonical provider's OpenAI platform defaults.
|
||||
const stamped = modelsToMerge.map((m) => buildMergedLegacyOpenAIModel(m, normalized.value));
|
||||
if (stamped.length > 0) {
|
||||
providers[canonicalKey] = { ...canonical, models: [...canonicalModels, ...stamped] };
|
||||
const mergedIds = stamped
|
||||
.map((m) => {
|
||||
const mr = getRecord(m);
|
||||
return typeof mr?.id === "string" && mr.id
|
||||
? mr.id
|
||||
: typeof mr?.name === "string" && mr.name
|
||||
? mr.name
|
||||
: "unknown";
|
||||
})
|
||||
.join(", ");
|
||||
changes.push(
|
||||
`Merged ${stamped.length} model(s) from models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} into models.providers.${OPENAI_PROVIDER_ID}: ${mergedIds}.`,
|
||||
);
|
||||
} else {
|
||||
changes.push(
|
||||
`Removed models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} because models.providers.${OPENAI_PROVIDER_ID} already exists.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
delete providers[providerId];
|
||||
providersChanged = true;
|
||||
@@ -1038,14 +1302,7 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS: LegacyConfigMigrationSpec[
|
||||
path: ["models", "providers"],
|
||||
message:
|
||||
'models.providers.openai-codex is legacy; run "openclaw doctor --fix" to move it to models.providers.openai.',
|
||||
match: (value) => {
|
||||
const providers = getRecord(value);
|
||||
return providers
|
||||
? Object.keys(providers).some(
|
||||
(providerId) => normalizeProviderId(providerId) === LEGACY_OPENAI_CODEX_PROVIDER_ID,
|
||||
)
|
||||
: false;
|
||||
},
|
||||
match: (value) => hasAutoFixableLegacyOpenAICodexProvider(value),
|
||||
},
|
||||
{
|
||||
path: ["models", "providers"],
|
||||
|
||||
@@ -262,7 +262,7 @@ describe("doctor preview warnings", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
} as unknown as OpenClawConfig,
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
env: { CODEX_HOME: codexHome, HOME: root },
|
||||
});
|
||||
@@ -297,6 +297,36 @@ describe("doctor preview warnings", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("warns when a normalized legacy Codex provider cannot be auto-merged", async () => {
|
||||
const warnings = await collectDoctorPreviewWarnings({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
params: { store: true },
|
||||
models: [{ id: "text-embedding-3-small" }],
|
||||
},
|
||||
"openai-codex": {
|
||||
api: "openai-chatgpt-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
models: [{ id: "gpt-5.5", api: "openai-chatgpt-responses" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
});
|
||||
|
||||
const warning = expectSingleWarningContaining(
|
||||
warnings,
|
||||
"models.providers.openai-codex cannot be merged automatically",
|
||||
);
|
||||
expect(warning).toContain("models.providers.openai.params");
|
||||
expect(warning).toContain("Move the affected model/provider defaults manually");
|
||||
});
|
||||
|
||||
it("sanitizes empty-allowlist warning paths before returning preview output", async () => {
|
||||
const warnings = await collectDoctorPreviewWarnings({
|
||||
cfg: {
|
||||
|
||||
@@ -779,6 +779,9 @@ export async function collectDoctorPreviewNotes(params: {
|
||||
warnings.push(...collectVisibleReplyToolPolicyWarnings(params.cfg));
|
||||
warnings.push(...collectChannelBoundMessageToolPolicyWarnings(params.cfg));
|
||||
warnings.push(...collectProfileConfiguredToolSectionWarnings(params.cfg));
|
||||
const { collectBlockedLegacyOpenAICodexProviderWarnings } =
|
||||
await import("./legacy-config-migrations.runtime.models.js");
|
||||
warnings.push(...collectBlockedLegacyOpenAICodexProviderWarnings(params.cfg));
|
||||
|
||||
const { collectActiveToolSchemaProjectionWarnings } =
|
||||
await import("./active-tool-schema-warnings.js");
|
||||
|
||||
Reference in New Issue
Block a user