diff --git a/extensions/microsoft-foundry/cli.ts b/extensions/microsoft-foundry/cli.ts index ba1461f9d457..05c22df78871 100644 --- a/extensions/microsoft-foundry/cli.ts +++ b/extensions/microsoft-foundry/cli.ts @@ -113,19 +113,19 @@ function parseAzJson(raw: string, label: string): unknown { } type AccessTokenParams = { + scope?: string; subscriptionId?: string; tenantId?: string; }; function buildAccessTokenArgs(params?: AccessTokenParams): string[] { - const args = [ - "account", - "get-access-token", - "--resource", - COGNITIVE_SERVICES_RESOURCE, - "--output", - "json", - ]; + const args = ["account", "get-access-token"]; + if (params?.scope) { + args.push("--scope", params.scope); + } else { + args.push("--resource", COGNITIVE_SERVICES_RESOURCE); + } + args.push("--output", "json"); if (params?.subscriptionId) { args.push("--subscription", params.subscriptionId); } else if (params?.tenantId) { diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index 02883438c533..335845e18691 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -14,6 +14,8 @@ import { } from "./onboard.js"; import { resetFoundryRuntimeAuthCaches } from "./runtime.js"; import { + COGNITIVE_SERVICES_RESOURCE, + FOUNDRY_ANTHROPIC_SCOPE, buildFoundryAuthResult, isAnthropicFoundryDeployment, isFoundryMaiImageModel, @@ -436,6 +438,9 @@ describe("microsoft-foundry plugin", () => { ); expect(prepared.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1"); + expect(execFileMock.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining(["--resource", COGNITIVE_SERVICES_RESOURCE]), + ); }); it("uses active model routing when Entra metadata points at another deployment", async () => { @@ -466,6 +471,61 @@ describe("microsoft-foundry plugin", () => { ); expect(prepared.baseUrl).toBe("https://example.services.ai.azure.com/anthropic"); + expect(execFileMock.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining(["--scope", FOUNDRY_ANTHROPIC_SCOPE]), + ); + }); + + it("does not reuse OpenAI Entra tokens for Anthropic Foundry deployments", async () => { + const provider = registerProvider(); + const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider); + mockAzureCliToken({ accessToken: "gpt-token", expiresInMs: 60_000 }); + mockAzureCliToken({ accessToken: "claude-token", expiresInMs: 60_000 }); + ensureAuthProfileStoreMock.mockReturnValue( + buildEntraProfileStore({ + endpoint: "https://example.services.ai.azure.com", + modelId: "deployment-gpt5", + modelName: "gpt-5.4", + api: "openai-responses", + }), + ); + + const gptPrepared = requireRuntimeAuthResult( + await prepareRuntimeAuth( + buildFoundryRuntimeAuthContext({ + modelId: "deployment-gpt5", + model: buildFoundryModel({ + id: "deployment-gpt5", + name: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://example.services.ai.azure.com/openai/v1", + }), + }), + ), + ); + const claudePrepared = requireRuntimeAuthResult( + await prepareRuntimeAuth( + buildFoundryRuntimeAuthContext({ + modelId: "deployment-fable", + model: buildFoundryModel({ + id: "deployment-fable", + name: "claude-fable-5", + api: "anthropic-messages", + baseUrl: "https://example.services.ai.azure.com/anthropic", + }), + }), + ), + ); + + expect(gptPrepared.apiKey).toBe("gpt-token"); + expect(claudePrepared.apiKey).toBe("claude-token"); + expect(execFileMock).toHaveBeenCalledTimes(2); + expect(execFileMock.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining(["--resource", COGNITIVE_SERVICES_RESOURCE]), + ); + expect(execFileMock.mock.calls[1]?.[1]).toEqual( + expect.arrayContaining(["--scope", FOUNDRY_ANTHROPIC_SCOPE]), + ); }); it("retries Entra token refresh after a failed attempt", async () => { diff --git a/extensions/microsoft-foundry/runtime.ts b/extensions/microsoft-foundry/runtime.ts index d25122032445..e382e50635b3 100644 --- a/extensions/microsoft-foundry/runtime.ts +++ b/extensions/microsoft-foundry/runtime.ts @@ -10,7 +10,9 @@ import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getAccessTokenResultAsync } from "./cli.js"; import { + ANTHROPIC_MESSAGES_API, type CachedTokenEntry, + FOUNDRY_ANTHROPIC_SCOPE, TOKEN_REFRESH_MARGIN_MS, buildFoundryProviderBaseUrl, extractFoundryEndpoint, @@ -29,6 +31,7 @@ export function resetFoundryRuntimeAuthCaches(): void { } async function refreshEntraToken(params?: { + scope?: string; subscriptionId?: string; tenantId?: string; }): Promise<{ apiKey: string; expiresAt: number }> { @@ -78,10 +81,13 @@ export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthC const endpoint = extractFoundryEndpoint(ctx.model.baseUrl ?? "") ?? normalizeOptionalString(metadata?.endpoint); + const tokenScope = + configuredApi === ANTHROPIC_MESSAGES_API ? FOUNDRY_ANTHROPIC_SCOPE : undefined; const baseUrl = endpoint ? buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, configuredApi) : undefined; const cacheKey = getFoundryTokenCacheKey({ + scope: tokenScope, subscriptionId: metadata?.subscriptionId, tenantId: metadata?.tenantId, }); @@ -101,6 +107,7 @@ export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthC let refreshPromise = refreshPromises.get(cacheKey); if (!refreshPromise) { refreshPromise = refreshEntraToken({ + scope: tokenScope, subscriptionId: metadata?.subscriptionId, tenantId: metadata?.tenantId, }).finally(() => { diff --git a/extensions/microsoft-foundry/shared-runtime.ts b/extensions/microsoft-foundry/shared-runtime.ts index 2dd68d3b7cef..2533eaba32c3 100644 --- a/extensions/microsoft-foundry/shared-runtime.ts +++ b/extensions/microsoft-foundry/shared-runtime.ts @@ -3,14 +3,17 @@ export { TOKEN_REFRESH_MARGIN_MS, buildFoundryProviderBaseUrl, extractFoundryEndpoint, + FOUNDRY_ANTHROPIC_SCOPE, isFoundryProviderApi, resolveConfiguredModelNameHint, + ANTHROPIC_MESSAGES_API, type CachedTokenEntry, } from "./shared.js"; export function getFoundryTokenCacheKey(params?: { + scope?: string; subscriptionId?: string; tenantId?: string; }): string { - return `${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`; + return `${params?.scope ?? ""}:${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`; } diff --git a/extensions/microsoft-foundry/shared.ts b/extensions/microsoft-foundry/shared.ts index 40c22819fa5b..684ce6ab3acb 100644 --- a/extensions/microsoft-foundry/shared.ts +++ b/extensions/microsoft-foundry/shared.ts @@ -16,6 +16,7 @@ export const PROVIDER_ID = "microsoft-foundry"; export const DEFAULT_API = "openai-completions"; export const DEFAULT_GPT5_API = "openai-responses"; export const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com"; +export const FOUNDRY_ANTHROPIC_SCOPE = "https://ai.azure.com/.default"; export const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; export const MAI_IMAGE_MODELS = [ "MAI-Image-2.5-Flash",