From a777b82da0325c2b5453a7aa8937436a2c9e302d Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 29 Apr 2026 16:46:57 +0100 Subject: [PATCH] feat: add model list auth index --- src/agents/model-auth-env.ts | 14 +++- src/commands/models/list.auth-index.test.ts | 79 ++++++++++++++++++++ src/commands/models/list.auth-index.ts | 83 +++++++++++++++++++++ 3 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/commands/models/list.auth-index.test.ts create mode 100644 src/commands/models/list.auth-index.ts diff --git a/src/agents/model-auth-env.ts b/src/agents/model-auth-env.ts index 7f8c3c6b0dd..71e5a44d593 100644 --- a/src/agents/model-auth-env.ts +++ b/src/agents/model-auth-env.ts @@ -7,12 +7,18 @@ import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js import { resolveProviderEnvApiKeyCandidates } from "./model-auth-env-vars.js"; import { GCP_VERTEX_CREDENTIALS_MARKER } from "./model-auth-markers.js"; import { resolveProviderIdForAuth } from "./provider-auth-aliases.js"; +import { normalizeProviderIdForAuth } from "./provider-id.js"; export type EnvApiKeyResult = { apiKey: string; source: string; }; +export type EnvApiKeyLookupOptions = { + aliasMap?: Readonly>; + candidateMap?: Readonly>; +}; + function hasGoogleVertexAdcCredentials(env: NodeJS.ProcessEnv): boolean { const explicitCredentialsPath = normalizeOptionalSecretInput(env.GOOGLE_APPLICATION_CREDENTIALS); if (explicitCredentialsPath) { @@ -39,9 +45,13 @@ function resolveGoogleVertexEnvApiKey(env: NodeJS.ProcessEnv): string | undefine export function resolveEnvApiKey( provider: string, env: NodeJS.ProcessEnv = process.env, + options: EnvApiKeyLookupOptions = {}, ): EnvApiKeyResult | null { - const normalized = resolveProviderIdForAuth(provider, { env }); - const candidateMap = resolveProviderEnvApiKeyCandidates({ env }); + const normalizedProvider = normalizeProviderIdForAuth(provider); + const normalized = options.aliasMap + ? (options.aliasMap[normalizedProvider] ?? normalizedProvider) + : resolveProviderIdForAuth(provider, { env }); + const candidateMap = options.candidateMap ?? resolveProviderEnvApiKeyCandidates({ env }); const applied = new Set(getShellEnvAppliedKeys()); const pick = (envVar: string): EnvApiKeyResult | null => { const value = normalizeOptionalSecretInput(env[envVar]); diff --git a/src/commands/models/list.auth-index.test.ts b/src/commands/models/list.auth-index.test.ts new file mode 100644 index 00000000000..98227837592 --- /dev/null +++ b/src/commands/models/list.auth-index.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; +import { createModelListAuthIndex } from "./list.auth-index.js"; + +vi.mock("../../plugins/installed-plugin-index-store.js", () => ({ + readPersistedInstalledPluginIndexSync: vi.fn(() => null), +})); + +const emptyStore: AuthProfileStore = { + version: 1, + profiles: {}, +}; + +describe("createModelListAuthIndex", () => { + it("normalizes auth aliases from profiles", () => { + const index = createModelListAuthIndex({ + cfg: {}, + authStore: { + version: 1, + profiles: { + "byteplus:default": { + type: "api_key", + provider: "byteplus", + key: "sk-test", + }, + }, + }, + env: {}, + }); + + expect(index.hasProviderAuth("byteplus")).toBe(true); + expect(index.hasProviderAuth("byteplus-plan")).toBe(true); + }); + + it("records env-backed providers without resolving env candidates per row", () => { + const index = createModelListAuthIndex({ + cfg: {}, + authStore: emptyStore, + env: { + MOONSHOT_API_KEY: "sk-test", + }, + }); + + expect(index.hasProviderAuth("moonshot")).toBe(true); + expect(index.hasProviderAuth("openai")).toBe(false); + }); + + it("records configured provider API keys", () => { + const index = createModelListAuthIndex({ + cfg: { + models: { + providers: { + "custom-openai": { + api: "openai-completions", + apiKey: "sk-configured", + baseUrl: "https://custom.example/v1", + models: [{ id: "local-model" }], + }, + }, + }, + }, + authStore: emptyStore, + env: {}, + }); + + expect(index.hasProviderAuth("custom-openai")).toBe(true); + }); + + it("uses injected synthetic auth refs without loading provider runtime", () => { + const index = createModelListAuthIndex({ + cfg: {}, + authStore: emptyStore, + env: {}, + syntheticAuthProviderRefs: ["codex"], + }); + + expect(index.hasProviderAuth("codex")).toBe(true); + }); +}); diff --git a/src/commands/models/list.auth-index.ts b/src/commands/models/list.auth-index.ts new file mode 100644 index 00000000000..26284a9563f --- /dev/null +++ b/src/commands/models/list.auth-index.ts @@ -0,0 +1,83 @@ +import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; +import { resolveProviderEnvApiKeyCandidates } from "../../agents/model-auth-env-vars.js"; +import { resolveEnvApiKey } from "../../agents/model-auth-env.js"; +import { resolveAwsSdkEnvVarName } from "../../agents/model-auth-runtime-shared.js"; +import { hasUsableCustomProviderApiKey } from "../../agents/model-auth.js"; +import { resolveProviderAuthAliasMap } from "../../agents/provider-auth-aliases.js"; +import { normalizeProviderIdForAuth } from "../../agents/provider-id.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { readPersistedInstalledPluginIndexSync } from "../../plugins/installed-plugin-index-store.js"; + +export type ModelListAuthIndex = { + hasProviderAuth(provider: string): boolean; +}; + +export type CreateModelListAuthIndexParams = { + cfg: OpenClawConfig; + authStore: AuthProfileStore; + env?: NodeJS.ProcessEnv; + syntheticAuthProviderRefs?: readonly string[]; +}; + +export const EMPTY_MODEL_LIST_AUTH_INDEX: ModelListAuthIndex = { + hasProviderAuth: () => false, +}; + +function normalizeAuthProvider( + provider: string, + aliasMap: Readonly>, +): string { + const normalized = normalizeProviderIdForAuth(provider); + return aliasMap[normalized] ?? normalized; +} + +function listPersistedSyntheticAuthProviderRefs(): readonly string[] { + const index = readPersistedInstalledPluginIndexSync(); + return index?.plugins.flatMap((plugin) => plugin.syntheticAuthRefs ?? []) ?? []; +} + +export function createModelListAuthIndex( + params: CreateModelListAuthIndexParams, +): ModelListAuthIndex { + const env = params.env ?? process.env; + const aliasMap = resolveProviderAuthAliasMap({ config: params.cfg, env }); + const envCandidateMap = resolveProviderEnvApiKeyCandidates({ config: params.cfg, env }); + const authenticatedProviders = new Set(); + const addProvider = (provider: string | undefined) => { + if (!provider?.trim()) { + return; + } + authenticatedProviders.add(normalizeAuthProvider(provider, aliasMap)); + }; + + for (const credential of Object.values(params.authStore.profiles ?? {})) { + addProvider(credential.provider); + } + + for (const provider of Object.keys(envCandidateMap)) { + if (resolveEnvApiKey(provider, env, { aliasMap, candidateMap: envCandidateMap })) { + addProvider(provider); + } + } + + if (resolveAwsSdkEnvVarName(env)) { + addProvider("amazon-bedrock"); + } + + for (const provider of Object.keys(params.cfg.models?.providers ?? {})) { + if (hasUsableCustomProviderApiKey(params.cfg, provider, env)) { + addProvider(provider); + } + } + + for (const provider of params.syntheticAuthProviderRefs ?? + listPersistedSyntheticAuthProviderRefs()) { + addProvider(provider); + } + + return { + hasProviderAuth(provider: string): boolean { + return authenticatedProviders.has(normalizeAuthProvider(provider, aliasMap)); + }, + }; +}