mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"enabledByDefault": true,
|
||||
"providers": ["anthropic-vertex"],
|
||||
"providerDiscoveryEntry": "./provider-discovery.ts",
|
||||
"syntheticAuthRefs": ["anthropic-vertex"],
|
||||
"nonSecretAuthMarkers": ["gcp-vertex-credentials"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user