fix: clear stalled model resolution lanes

This commit is contained in:
Peter Steinberger
2026-03-30 05:09:01 +09:00
parent 9b4f26e70a
commit 3f5ed11266
4 changed files with 135 additions and 31 deletions

View File

@@ -22,6 +22,10 @@ type DynamicModelContext = {
};
type ResolvedModelLike = Record<string, unknown>;
type NormalizedTransportLike = {
api?: string | null;
baseUrl?: string;
};
type ProviderRuntimeTestMockOptions = {
clearHookCache?: () => void;
@@ -78,6 +82,42 @@ function normalizeDynamicModel(params: { provider: string; model: ResolvedModelL
return undefined;
}
function normalizeTransport(params: {
provider: string;
context: { api?: string | null; baseUrl?: string };
}): NormalizedTransportLike | undefined {
const isNativeOpenAiTransport =
params.context.api === "openai-completions" &&
(params.context.baseUrl === OPENAI_BASE_URL ||
(params.provider === "openai" && !params.context.baseUrl));
const isNativeXaiTransport =
params.context.api === "openai-completions" &&
(params.context.baseUrl === XAI_BASE_URL ||
(params.provider === "xai" && !params.context.baseUrl));
if (
params.context.api === "google-generative-ai" &&
params.context.baseUrl === "https://generativelanguage.googleapis.com"
) {
return {
api: params.context.api,
baseUrl: GOOGLE_GENERATIVE_AI_BASE_URL,
};
}
if (isNativeOpenAiTransport) {
return {
api: "openai-responses",
baseUrl: params.context.baseUrl,
};
}
if (isNativeXaiTransport) {
return {
api: "openai-responses",
baseUrl: params.context.baseUrl,
};
}
return undefined;
}
function buildDynamicModel(
params: DynamicModelContext,
options: Required<
@@ -384,38 +424,38 @@ export function createProviderRuntimeTestMock(options: ProviderRuntimeTestMockOp
model: params.context.model as ResolvedModelLike,
})
: undefined,
applyProviderResolvedTransportWithPlugin: (params: {
provider: string;
config?: unknown;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: { model: unknown };
}) => {
const model = params.context.model as ResolvedModelLike;
const normalized = normalizeTransport({
provider: params.provider,
context: {
api: model.api as string | null | undefined,
baseUrl: model.baseUrl as string | undefined,
},
});
if (!normalized) {
return undefined;
}
const nextApi = normalized.api ?? model.api;
const nextBaseUrl = normalized.baseUrl ?? model.baseUrl;
if (nextApi === model.api && nextBaseUrl === model.baseUrl) {
return undefined;
}
return {
...model,
api: nextApi,
baseUrl: nextBaseUrl,
};
},
normalizeProviderTransportWithPlugin: (params: {
provider: string;
context: { api?: string | null; baseUrl?: string };
}) => {
if (
params.context.api === "google-generative-ai" &&
params.context.baseUrl === "https://generativelanguage.googleapis.com"
) {
return {
api: params.context.api,
baseUrl: GOOGLE_GENERATIVE_AI_BASE_URL,
};
}
if (
params.context.api === "openai-completions" &&
(params.provider === "openai" || params.context.baseUrl === OPENAI_BASE_URL)
) {
return {
api: "openai-responses",
baseUrl: params.context.baseUrl,
};
}
if (
params.context.api === "openai-completions" &&
(params.provider === "xai" || params.context.baseUrl === XAI_BASE_URL)
) {
return {
api: "openai-responses",
baseUrl: params.context.baseUrl,
};
}
return undefined;
},
}) => normalizeTransport(params),
};
}

View File

@@ -1106,6 +1106,15 @@ describe("resolveModel", () => {
buildProviderUnknownModelHintWithPlugin: () => undefined,
prepareProviderDynamicModel: async () => {},
runProviderDynamicModel: () => undefined,
applyProviderResolvedTransportWithPlugin: ({ provider, context }) =>
provider === "xai" &&
context.model.api === "openai-completions" &&
context.model.baseUrl === "https://api.x.ai/v1"
? {
...context.model,
api: "openai-responses",
}
: undefined,
normalizeProviderResolvedModelWithPlugin: ({ provider, context }) =>
provider === "xai" ? (context.model as never) : undefined,
normalizeProviderTransportWithPlugin: () => undefined,

View File

@@ -105,6 +105,36 @@ function sanitizeModelHeaders(
return Object.keys(next).length > 0 ? next : undefined;
}
function applyResolvedTransportFallback(params: {
provider: string;
cfg?: OpenClawConfig;
runtimeHooks: ProviderRuntimeHooks;
model: Model<Api>;
}): Model<Api> | undefined {
const normalized = params.runtimeHooks.normalizeProviderTransportWithPlugin({
provider: params.provider,
config: params.cfg,
context: {
provider: params.provider,
api: params.model.api,
baseUrl: params.model.baseUrl,
},
}) as { api?: Api | null; baseUrl?: string } | undefined;
if (!normalized) {
return undefined;
}
const nextApi = normalizeResolvedTransportApi(normalized.api) ?? params.model.api;
const nextBaseUrl = normalized.baseUrl ?? params.model.baseUrl;
if (nextApi === params.model.api && nextBaseUrl === params.model.baseUrl) {
return undefined;
}
return {
...params.model,
api: nextApi,
baseUrl: nextBaseUrl,
};
}
function normalizeResolvedModel(params: {
provider: string;
model: Model<Api>;
@@ -153,9 +183,18 @@ function normalizeResolvedModel(params: {
model: (compatNormalized ?? pluginNormalized ?? normalizedInputModel) as never,
},
}) as Model<Api> | undefined;
const fallbackTransportNormalized =
transportNormalized ??
applyResolvedTransportFallback({
provider: params.provider,
cfg: params.cfg,
runtimeHooks,
model: compatNormalized ?? pluginNormalized ?? normalizedInputModel,
});
return normalizeResolvedProviderModel({
provider: params.provider,
model: transportNormalized ?? compatNormalized ?? pluginNormalized ?? normalizedInputModel,
model:
fallbackTransportNormalized ?? compatNormalized ?? pluginNormalized ?? normalizedInputModel,
});
}

View File

@@ -169,6 +169,13 @@ const SETUP_BARREL_GUARDS: GuardedSource[] = [
},
];
const CHANNEL_CONFIG_SCHEMA_GUARDS: GuardedSource[] = [
{
path: bundledPluginFile("tlon", "src/config-schema.ts"),
forbiddenPatterns: [/["']openclaw\/plugin-sdk\/core["']/],
},
];
const LOCAL_EXTENSION_API_BARREL_GUARDS = [
"acpx",
"bluebubbles",
@@ -469,6 +476,15 @@ describe("channel import guardrails", () => {
}
});
it("keeps channel config schemas off the broad core sdk barrel", () => {
for (const source of CHANNEL_CONFIG_SCHEMA_GUARDS) {
const text = readSource(source.path);
for (const pattern of source.forbiddenPatterns) {
expect(text, `${source.path} should not match ${pattern}`).not.toMatch(pattern);
}
}
});
it("keeps bundled extension source files off root and compat plugin-sdk imports", () => {
for (const file of collectExtensionSourceFiles()) {
const analysis = getSourceAnalysis(file);