fix(foundry): scope Entra tokens by API

This commit is contained in:
Vincent Koc
2026-06-10 14:12:21 +09:00
parent 3ba617681f
commit 2fa9a5eaa0
5 changed files with 80 additions and 9 deletions

View File

@@ -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) {

View File

@@ -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 () => {

View File

@@ -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(() => {

View File

@@ -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 ?? ""}`;
}

View File

@@ -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",