fix(gateway): keep unresolved profile refs unknown

This commit is contained in:
Ayaan Zaidi
2026-06-05 16:21:28 +05:30
parent 7c885528ba
commit ea1ef72394
2 changed files with 139 additions and 33 deletions

View File

@@ -20,11 +20,7 @@ import {
loadModelCatalogForBrowse,
type ModelCatalogBrowseView,
} from "../../agents/model-catalog-browse.js";
import {
modelCatalogEntryHasProviderAuth,
type ProviderAuthChecker,
resolveVisibleModelCatalog,
} from "../../agents/model-catalog-visibility.js";
import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -33,9 +29,23 @@ import type { GatewayRequestContext } from "./types.js";
type ModelsListView = ModelCatalogBrowseView;
type ModelsListEntry = ModelCatalogEntry & { available?: boolean };
type ModelsListAvailability = boolean | undefined;
type ModelsListProviderAuthChecker = (
provider: string,
modelApi?: string,
) => ModelsListAvailability | Promise<ModelsListAvailability>;
let loggedSlowModelsListCatalog = false;
const OAUTH_REFRESH_MARGIN_MS = 5 * 60 * 1000;
const OPENAI_PROVIDER_ID = "openai";
const OPENAI_CODEX_RESPONSES_API = "openai-chatgpt-responses";
const OPENAI_CODEX_ROUTABLE_MODEL_IDS = new Set([
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
"gpt-5.4-pro",
"gpt-5.4-mini",
]);
// Unknown views are rejected by protocol validation first; this helper keeps the
// handler default explicit for older clients that omit the field.
@@ -65,9 +75,9 @@ function modelCatalogEntryHasUnknownSecretRefAvailability(
}
function createInFlightProviderAuthChecker(
providerAuthChecker: ProviderAuthChecker,
): ProviderAuthChecker {
const pending = new Map<string, Promise<boolean>>();
providerAuthChecker: ModelsListProviderAuthChecker,
): ModelsListProviderAuthChecker {
const pending = new Map<string, Promise<ModelsListAvailability>>();
return (provider, modelApi) => {
const key = `${normalizeProviderId(provider)}\0${modelApi ?? ""}`;
const cached = pending.get(key);
@@ -88,6 +98,10 @@ function hasAvailableEnvSecretRef(value: unknown): boolean {
return isSecretRef(value) && value.source === "env" && hasLiteralSecret(process.env[value.id]);
}
function hasSecretRef(value: unknown): boolean {
return isSecretRef(value);
}
function profileModeAllowedForModel(
provider: string,
modelApi: string | undefined,
@@ -106,21 +120,27 @@ function profileHasReadOnlyAvailableAuth(params: {
provider: string;
modelApi?: string;
now: number;
}): boolean {
}): ModelsListAvailability {
if (!profileModeAllowedForModel(params.provider, params.modelApi, params.credential.type)) {
return false;
}
if (params.credential.type === "api_key") {
return (
hasLiteralSecret(params.credential.key) || hasAvailableEnvSecretRef(params.credential.keyRef)
);
if (
hasLiteralSecret(params.credential.key) ||
hasAvailableEnvSecretRef(params.credential.keyRef)
) {
return true;
}
return hasSecretRef(params.credential.keyRef) ? undefined : false;
}
if (params.credential.type === "token") {
return (
(hasLiteralSecret(params.credential.token) ||
hasAvailableEnvSecretRef(params.credential.tokenRef)) &&
(params.credential.expires === undefined || params.credential.expires > params.now)
);
const hasCurrentToken =
hasLiteralSecret(params.credential.token) ||
hasAvailableEnvSecretRef(params.credential.tokenRef);
if (hasCurrentToken) {
return params.credential.expires === undefined || params.credential.expires > params.now;
}
return hasSecretRef(params.credential.tokenRef) ? undefined : false;
}
return (
hasLiteralSecret(params.credential.access) &&
@@ -133,31 +153,39 @@ function hasReadOnlyAvailableProfileAuth(params: {
modelApi?: string;
cfg: OpenClawConfig;
store: AuthProfileStore;
}): boolean {
}): ModelsListAvailability {
const now = Date.now();
return resolveAuthProfileOrder({
let sawUnknown = false;
for (const profileId of resolveAuthProfileOrder({
cfg: params.cfg,
store: params.store,
provider: params.provider,
}).some((profileId) => {
})) {
const credential = params.store.profiles[profileId];
return (
credential !== undefined &&
profileHasReadOnlyAvailableAuth({
credential,
provider: params.provider,
modelApi: params.modelApi,
now,
})
);
});
if (!credential) {
continue;
}
const available = profileHasReadOnlyAvailableAuth({
credential,
provider: params.provider,
modelApi: params.modelApi,
now,
});
if (available === true) {
return true;
}
if (available === undefined) {
sawUnknown = true;
}
}
return sawUnknown ? undefined : false;
}
function createModelsListProviderAuthChecker(params: {
cfg: OpenClawConfig;
agentId: string;
workspaceDir: string;
}): ProviderAuthChecker {
}): ModelsListProviderAuthChecker {
const agentDir = resolveAgentDir(params.cfg, params.agentId);
const store = ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
allowKeychainPrompt: false,
@@ -182,10 +210,31 @@ function createModelsListProviderAuthChecker(params: {
);
}
function isCodexRoutableOpenAIPlatformCatalogEntry(entry: ModelCatalogEntry): boolean {
return (
normalizeProviderId(entry.provider) === OPENAI_PROVIDER_ID &&
entry.api !== undefined &&
entry.api !== OPENAI_CODEX_RESPONSES_API &&
OPENAI_CODEX_ROUTABLE_MODEL_IDS.has(entry.id.trim().toLowerCase())
);
}
async function resolveModelsListEntryAvailability(
providerAuthChecker: ModelsListProviderAuthChecker,
entry: ModelCatalogEntry,
): Promise<ModelsListAvailability> {
const primary = await providerAuthChecker(entry.provider, entry.api);
if (primary === true || !isCodexRoutableOpenAIPlatformCatalogEntry(entry)) {
return primary;
}
const codexResponses = await providerAuthChecker(entry.provider, OPENAI_CODEX_RESPONSES_API);
return codexResponses ?? primary;
}
async function buildPublicModelsListEntry(params: {
entry: ModelCatalogEntry;
cfg: OpenClawConfig;
providerAuthChecker?: ProviderAuthChecker;
providerAuthChecker?: ModelsListProviderAuthChecker;
}): Promise<ModelsListEntry> {
const publicEntry = omitRuntimeModelParams(params.entry);
if (modelCatalogEntryHasUnknownSecretRefAvailability(params.cfg, params.entry)) {
@@ -194,9 +243,16 @@ async function buildPublicModelsListEntry(params: {
if (!params.providerAuthChecker) {
return publicEntry;
}
const available = await resolveModelsListEntryAvailability(
params.providerAuthChecker,
params.entry,
);
if (available === undefined) {
return publicEntry;
}
return {
...publicEntry,
available: await modelCatalogEntryHasProviderAuth(params.providerAuthChecker, params.entry),
available,
};
}

View File

@@ -435,6 +435,56 @@ describe("models.list", () => {
);
});
it("keeps non-env SecretRef-backed auth profile availability unknown", async () => {
await withOpenClawTestState(
{
layout: "state-only",
prefix: "openclaw-models-list-file-profile-",
agentEnv: "main",
},
async (state) => {
await state.writeAuthProfiles({
version: 1,
profiles: {
"demo-provider:file": {
type: "token",
provider: "demo-provider",
tokenRef: {
source: "file",
provider: "mounted-json",
id: "/providers/demo/token",
},
expires: Date.now() + 60_000,
},
},
});
const { request, respond } = requestModelsList({
view: "all",
loadGatewayModelCatalog: vi.fn(() =>
Promise.resolve([{ id: "demo-model", name: "Demo Model", provider: "demo-provider" }]),
),
reqId: "req-models-list-file-profile",
});
await request;
expect(respond).toHaveBeenCalledWith(
true,
{
models: [
{
id: "demo-model",
name: "Demo Model",
provider: "demo-provider",
},
],
},
undefined,
);
},
);
});
it("preserves catalog load errors before the timeout fallback wins", async () => {
const { request, respond } = requestModelsList({
view: "configured",