fix(anthropic-vertex): resolve model discovery and auth for GCP ADC (openclaw#65716)

Verified:
- pnpm test extensions/anthropic-vertex/index.test.ts extensions/anthropic-vertex/region.adc.test.ts src/plugins/manifest-registry.test.ts src/plugins/provider-runtime.synthetic-auth-discovery.test.ts

Co-authored-by: feiskyer <676637+feiskyer@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Pengfei Ni
2026-04-23 22:03:16 +08:00
committed by GitHub
parent 5743d7c8f5
commit be4920f9bc
11 changed files with 241 additions and 14 deletions

View File

@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
- Thinking defaults/status: raise the implicit default thinking level for reasoning-capable models from legacy `off`/`low` fallback behavior to a safe provider-supported `medium` equivalent when no explicit config default is set, preserve configured-model reasoning metadata when runtime catalog loading is empty, and make `/status` report the same resolved default as runtime.
- Gateway/model pricing: fetch OpenRouter and LiteLLM pricing asynchronously at startup and extend catalog fetch timeouts to 30 seconds, reducing noisy timeout warnings during slow upstream responses.
- Providers/Anthropic Vertex: restore ADC-backed model discovery after the lightweight provider-discovery path by resolving emitted discovery entries, exposing synthetic auth on bootstrap discovery, and honoring copied env snapshots when probing the default GCP ADC path. Fixes #65715. (#65716) Thanks @feiskyer.
- Plugins/install: add newly installed plugin ids to an existing `plugins.allow` list before enabling them, so allowlisted configs load installed plugins after restart.
- Status: show `Fast` in `/status` when fast mode is enabled, including config/default-derived fast mode, and omit it when disabled.
- OpenAI/image generation: detect Azure OpenAI-style image endpoints, use Azure `api-key` auth plus deployment-scoped image URLs, and honor `AZURE_OPENAI_API_VERSION` so image generation and edits work against Azure-hosted OpenAI resources. (#70570) Thanks @zhanggpcsu.

View File

@@ -1,8 +1,29 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
const { hasAnthropicVertexAvailableAuthMock } = vi.hoisted(() => ({
hasAnthropicVertexAvailableAuthMock: vi.fn(),
}));
vi.mock("./api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./api.js")>();
return {
...actual,
hasAnthropicVertexAvailableAuth: hasAnthropicVertexAvailableAuthMock,
};
});
import anthropicVertexPlugin from "./index.js";
describe("anthropic-vertex provider plugin", () => {
beforeEach(() => {
hasAnthropicVertexAvailableAuthMock.mockReturnValue(true);
});
afterEach(() => {
vi.clearAllMocks();
});
it("resolves the ADC marker through the provider hook", async () => {
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
@@ -77,4 +98,34 @@ describe("anthropic-vertex provider plugin", () => {
allowSyntheticToolResults: true,
});
});
it("resolves synthetic auth when ADC is available", async () => {
hasAnthropicVertexAvailableAuthMock.mockReturnValue(true);
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
const result = provider.resolveSyntheticAuth?.({
provider: "anthropic-vertex",
config: undefined,
providerConfig: undefined,
} as never);
expect(result).toEqual({
apiKey: "gcp-vertex-credentials",
source: "gcp-vertex-credentials (ADC)",
mode: "api-key",
});
});
it("returns undefined when ADC is not available", async () => {
hasAnthropicVertexAvailableAuthMock.mockReturnValue(false);
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
const result = provider.resolveSyntheticAuth?.({
provider: "anthropic-vertex",
config: undefined,
providerConfig: undefined,
} as never);
expect(result).toBeUndefined();
});
});

View File

@@ -1,12 +1,15 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { readConfiguredProviderCatalogEntries } from "openclaw/plugin-sdk/provider-catalog-shared";
import { NATIVE_ANTHROPIC_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
import {
hasAnthropicVertexAvailableAuth,
mergeImplicitAnthropicVertexProvider,
resolveAnthropicVertexConfigApiKey,
resolveImplicitAnthropicVertexProvider,
} from "./api.js";
const PROVIDER_ID = "anthropic-vertex";
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
export default definePluginEntry({
id: PROVIDER_ID,
@@ -37,6 +40,21 @@ export default definePluginEntry({
},
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
...NATIVE_ANTHROPIC_REPLAY_HOOKS,
resolveSyntheticAuth: () => {
if (!hasAnthropicVertexAvailableAuth()) {
return undefined;
}
return {
apiKey: GCP_VERTEX_CREDENTIALS_MARKER,
source: "gcp-vertex-credentials (ADC)",
mode: "api-key",
};
},
augmentModelCatalog: ({ config }) =>
readConfiguredProviderCatalogEntries({
config,
providerId: PROVIDER_ID,
}),
});
},
});

View File

@@ -3,6 +3,7 @@
"enabledByDefault": true,
"providers": ["anthropic-vertex"],
"providerDiscoveryEntry": "./provider-discovery.ts",
"syntheticAuthRefs": ["anthropic-vertex"],
"nonSecretAuthMarkers": ["gcp-vertex-credentials"],
"configSchema": {
"type": "object",

View File

@@ -4,6 +4,7 @@ import { buildAnthropicVertexProvider } from "./provider-catalog.js";
import { hasAnthropicVertexAvailableAuth, resolveAnthropicVertexConfigApiKey } from "./region.js";
const PROVIDER_ID = "anthropic-vertex";
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
type AnthropicVertexProviderPlugin = {
id: string;
@@ -15,6 +16,13 @@ type AnthropicVertexProviderPlugin = {
run: (ctx: ProviderCatalogContext) => ReturnType<typeof runAnthropicVertexCatalog>;
};
resolveConfigApiKey: (params: { env: NodeJS.ProcessEnv }) => string | undefined;
resolveSyntheticAuth: () =>
| {
apiKey: string;
source: string;
mode: "api-key";
}
| undefined;
};
function mergeImplicitAnthropicVertexProvider(params: {
@@ -69,6 +77,16 @@ export const anthropicVertexProviderDiscovery: AnthropicVertexProviderPlugin = {
run: runAnthropicVertexCatalog,
},
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
resolveSyntheticAuth: () => {
if (!hasAnthropicVertexAvailableAuth()) {
return undefined;
}
return {
apiKey: GCP_VERTEX_CREDENTIALS_MARKER,
source: "gcp-vertex-credentials (ADC)",
mode: "api-key",
};
},
};
export default anthropicVertexProviderDiscovery;

View File

@@ -46,4 +46,28 @@ describe("anthropic-vertex ADC reads", () => {
expect(existsSyncMock).not.toHaveBeenCalled();
expect(readFileSyncMock).toHaveBeenCalledWith("/tmp/vertex-adc.json", "utf8");
});
it("respects HOME when probing the default ADC path from a copied env snapshot", () => {
const env = {
HOME: "/tmp/vertex-home",
} as NodeJS.ProcessEnv;
readFileSyncMock.mockImplementation((pathname, options) =>
String(pathname) === "/tmp/vertex-home/.config/gcloud/application_default_credentials.json"
? '{"project_id":"vertex-project"}'
: String(pathname) === "/tmp/vertex-adc.json"
? '{"project_id":"vertex-project"}'
: (() => {
throw new Error(`unexpected readFileSync(${String(pathname)}, ${String(options)})`);
})(),
);
expect(resolveAnthropicVertexProjectId(env)).toBe("vertex-project");
expect(hasAnthropicVertexAvailableAuth(env)).toBe(true);
expect(existsSyncMock).not.toHaveBeenCalled();
expect(readFileSyncMock).toHaveBeenCalledWith(
"/tmp/vertex-home/.config/gcloud/application_default_credentials.json",
"utf8",
);
});
});

View File

@@ -7,12 +7,6 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
const GCLOUD_DEFAULT_ADC_PATH = join(
homedir(),
".config",
"gcloud",
"application_default_credentials.json",
);
type AdcProjectFile = {
project_id?: unknown;
@@ -71,14 +65,28 @@ function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.en
);
}
function resolveAnthropicVertexHomeDir(env: NodeJS.ProcessEnv = process.env): string {
return (
normalizeOptionalSecretInput(env.HOME) ||
normalizeOptionalSecretInput(env.USERPROFILE) ||
homedir()
);
}
function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string {
return platform() === "win32"
? join(
env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
normalizeOptionalSecretInput(env.APPDATA) ??
join(resolveAnthropicVertexHomeDir(env), "AppData", "Roaming"),
"gcloud",
"application_default_credentials.json",
)
: GCLOUD_DEFAULT_ADC_PATH;
: join(
resolveAnthropicVertexHomeDir(env),
".config",
"gcloud",
"application_default_credentials.json",
);
}
function resolveAnthropicVertexAdcCredentialsPathCandidate(
@@ -88,9 +96,6 @@ function resolveAnthropicVertexAdcCredentialsPathCandidate(
if (explicit) {
return explicit;
}
if (env !== process.env) {
return undefined;
}
return resolveAnthropicVertexDefaultAdcPath(env);
}

View File

@@ -478,6 +478,27 @@ describe("loadPluginManifestRegistry", () => {
]);
});
it("falls back providerDiscoverySource from .ts to emitted .js files", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "anthropic-vertex",
providers: ["anthropic-vertex"],
providerDiscoveryEntry: "./provider-discovery.ts",
configSchema: { type: "object" },
});
fs.writeFileSync(path.join(dir, "provider-discovery.js"), "export default {};\n", "utf8");
const registry = loadSingleCandidateRegistry({
idHint: "anthropic-vertex",
rootDir: dir,
origin: "bundled",
});
expect(registry.plugins[0]?.providerDiscoverySource).toBe(
path.join(dir, "provider-discovery.js"),
);
});
it("preserves activation and setup descriptors from plugin manifests", () => {
const dir = makeTempDir();
writeManifest(dir, {

View File

@@ -45,6 +45,24 @@ import type { PluginKind } from "./plugin-kind.types.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import { resolvePluginCacheInputs } from "./roots.js";
/**
* Resolve a plugin source path, falling back from .ts to .js when the
* .ts file doesn't exist on disk (e.g. in dist builds where only .js
* is emitted but the manifest still references the .ts entry).
*/
function resolvePluginSourcePath(sourcePath: string): string {
if (fs.existsSync(sourcePath)) {
return sourcePath;
}
if (sourcePath.endsWith(".ts")) {
const jsPath = sourcePath.slice(0, -3) + ".js";
if (fs.existsSync(jsPath)) {
return jsPath;
}
}
return sourcePath;
}
type PluginManifestContractListKey =
| "speechProviders"
| "externalAuthProviders"
@@ -334,7 +352,9 @@ function buildRecord(params: {
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
providerDiscoverySource: params.manifest.providerDiscoveryEntry
? path.resolve(params.candidate.rootDir, params.manifest.providerDiscoveryEntry)
? resolvePluginSourcePath(
path.resolve(params.candidate.rootDir, params.manifest.providerDiscoveryEntry),
)
: undefined,
modelSupport: params.manifest.modelSupport,
providerEndpoints: params.manifest.providerEndpoints,

View File

@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from "vitest";
const resolveProviderRuntimePlugin = vi.hoisted(() => vi.fn(() => undefined));
const resolvePluginDiscoveryProvidersRuntime = vi.hoisted(() =>
vi.fn(() => [
{
id: "anthropic-vertex",
label: "Anthropic Vertex",
auth: [],
resolveSyntheticAuth: () => ({
apiKey: "gcp-vertex-credentials",
source: "gcp-vertex-credentials (ADC)",
mode: "api-key" as const,
}),
},
]),
);
vi.mock("./provider-hook-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./provider-hook-runtime.js")>();
return {
...actual,
__testing: {},
clearProviderRuntimeHookCache: vi.fn(),
prepareProviderExtraParams: vi.fn(),
resetProviderRuntimeHookCacheForTest: vi.fn(),
resolveProviderHookPlugin: vi.fn(),
resolveProviderPluginsForHooks: vi.fn(() => []),
resolveProviderRuntimePlugin,
wrapProviderStreamFn: vi.fn(),
};
});
vi.mock("./provider-discovery.runtime.js", () => ({
resolvePluginDiscoveryProvidersRuntime,
}));
import { resolveProviderSyntheticAuthWithPlugin } from "./provider-runtime.js";
describe("resolveProviderSyntheticAuthWithPlugin", () => {
it("falls back to lightweight discovery providers when runtime hooks are unavailable", () => {
expect(
resolveProviderSyntheticAuthWithPlugin({
provider: "anthropic-vertex",
context: {
config: undefined,
provider: "anthropic-vertex",
providerConfig: undefined,
},
}),
).toEqual({
apiKey: "gcp-vertex-credentials",
source: "gcp-vertex-credentials (ADC)",
mode: "api-key",
});
expect(resolveProviderRuntimePlugin).toHaveBeenCalled();
expect(resolvePluginDiscoveryProvidersRuntime).toHaveBeenCalled();
});
});

View File

@@ -20,6 +20,7 @@ import {
resolveProviderRuntimePlugin,
wrapProviderStreamFn,
} from "./provider-hook-runtime.js";
import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js";
import { resolveBundledProviderPolicySurface } from "./provider-public-artifacts.js";
import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js";
import type { ProviderThinkingProfile } from "./provider-thinking.types.js";
@@ -761,7 +762,15 @@ export function resolveProviderSyntheticAuthWithPlugin(params: {
env?: NodeJS.ProcessEnv;
context: ProviderResolveSyntheticAuthContext;
}) {
return resolveProviderRuntimePlugin(params)?.resolveSyntheticAuth?.(params.context) ?? undefined;
const runtimeResolved = resolveProviderRuntimePlugin(params)?.resolveSyntheticAuth?.(params.context);
if (runtimeResolved) {
return runtimeResolved;
}
return resolvePluginDiscoveryProvidersRuntime({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}).find((provider) => provider.id === params.provider)?.resolveSyntheticAuth?.(params.context);
}
export function resolveExternalAuthProfilesWithPlugins(params: {