fix(foundry): bind auth and thinking contracts

This commit is contained in:
Vincent Koc
2026-06-11 08:30:03 +09:00
parent 76ce9d6d22
commit 08655fb02b
3 changed files with 101 additions and 32 deletions

View File

@@ -91,7 +91,18 @@ function requirePrepareRuntimeAuth(
}
function requireRuntimeAuthResult(
result: { apiKey?: string; baseUrl?: string; expiresAt?: number } | undefined,
result:
| {
apiKey?: string;
baseUrl?: string;
expiresAt?: number;
request?: {
auth?:
| { mode: "authorization-bearer"; token: string }
| { mode: "header"; headerName: string; value: string };
};
}
| undefined,
) {
if (!result) {
throw new Error("expected Microsoft Foundry runtime auth result");
@@ -450,11 +461,41 @@ describe("microsoft-foundry plugin", () => {
);
expect(prepared.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1");
expect(prepared.request?.auth).toEqual({
mode: "authorization-bearer",
token: "test-token",
});
expect(execFileMock.mock.calls[0]?.[1]).toEqual(
expect.arrayContaining(["--resource", COGNITIVE_SERVICES_RESOURCE]),
);
});
it.each([
["openai-responses", "api-key"],
["anthropic-messages", "x-api-key"],
] as const)("binds %s API-key auth to the active profile", async (api, headerName) => {
const provider = registerProvider();
const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider);
const prepared = requireRuntimeAuthResult(
await prepareRuntimeAuth(
buildFoundryRuntimeAuthContext({
apiKey: "profile-api-key",
profileId: "microsoft-foundry:default",
model: buildFoundryModel({ api }),
}),
),
);
expect(prepared).toEqual({
apiKey: "profile-api-key",
request: {
auth: { mode: "header", headerName, value: "profile-api-key" },
},
});
expect(execFileMock).not.toHaveBeenCalled();
});
it("uses active model routing when Entra metadata points at another deployment", async () => {
const provider = registerProvider();
const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider);
@@ -1201,7 +1242,7 @@ describe("microsoft-foundry plugin", () => {
expect(normalized?.compat?.supportsReasoningEffort).toBe(false);
});
it("writes Azure API key header overrides for API-key auth configs", () => {
it("keeps API-key credentials scoped to auth profiles", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:default",
apiKey: "test-api-key",
@@ -1212,9 +1253,9 @@ describe("microsoft-foundry plugin", () => {
});
const provider = requireFoundryProviderPatch(result);
expect(provider.apiKey).toBe("test-api-key");
expect(provider.authHeader).toBe(false);
expect(provider.headers).toEqual({ "api-key": "test-api-key" });
expect(provider.apiKey).toBeUndefined();
expect(provider.authHeader).toBeUndefined();
expect(provider.headers).toBeUndefined();
});
it("uses the minimum supported response token count for GPT-5 connection tests", () => {
@@ -1331,7 +1372,7 @@ describe("microsoft-foundry plugin", () => {
const provider = result.configPatch?.models?.providers?.["microsoft-foundry"];
expect(provider?.baseUrl).toBe("https://example.services.ai.azure.com/anthropic");
expect(provider?.api).toBe("anthropic-messages");
expect(provider?.authHeader).toBe(true);
expect(provider?.authHeader).toBeUndefined();
expect(provider?.models[0]).toMatchObject({
id: "prod-fable",
name: "claude-fable-5",
@@ -1345,7 +1386,7 @@ describe("microsoft-foundry plugin", () => {
expect(provider?.models[0]?.compat).toBeUndefined();
});
it("clears stale API-key credentials when writing Entra provider patches", () => {
it("keeps Entra credentials scoped to auth profiles", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:entra",
apiKey: "__entra_id_dynamic__",
@@ -1359,9 +1400,9 @@ describe("microsoft-foundry plugin", () => {
const provider = result.configPatch?.models?.providers?.["microsoft-foundry"] as
| Record<string, unknown>
| undefined;
expect(provider?.authHeader).toBe(true);
expect(Object.hasOwn(provider ?? {}, "apiKey")).toBe(true);
expect(Object.hasOwn(provider ?? {}, "headers")).toBe(true);
expect(provider?.authHeader).toBeUndefined();
expect(Object.hasOwn(provider ?? {}, "apiKey")).toBe(false);
expect(Object.hasOwn(provider ?? {}, "headers")).toBe(false);
expect(provider?.apiKey).toBeUndefined();
expect(provider?.headers).toBeUndefined();
});
@@ -1463,6 +1504,28 @@ describe("microsoft-foundry plugin", () => {
],
});
}
for (const modelName of [
"claude-opus-4-1",
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
]) {
expect(
provider.resolveThinkingProfile?.({
provider: "microsoft-foundry",
modelId: `prod-${modelName}`,
params: { canonicalModelId: modelName },
}),
).toMatchObject({
levels: [
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
],
});
}
expect(
provider.resolveThinkingProfile?.({
provider: "microsoft-foundry",
@@ -1763,7 +1826,7 @@ describe("microsoft-foundry plugin", () => {
await expect(getAccessTokenResultAsync()).rejects.toThrow("Azure CLI is not logged in");
});
it("keeps Azure API key header overrides when API-key auth uses a secret ref", () => {
it("keeps API-key secret refs scoped to auth profiles", () => {
const secretRef = {
source: "env" as const,
provider: "default",
@@ -1779,9 +1842,9 @@ describe("microsoft-foundry plugin", () => {
});
const provider = requireFoundryProviderPatch(result);
expect(provider.apiKey).toBe(secretRef);
expect(provider.authHeader).toBe(false);
expect(provider.headers).toEqual({ "api-key": secretRef });
expect(provider.apiKey).toBeUndefined();
expect(provider.authHeader).toBeUndefined();
expect(provider.headers).toBeUndefined();
});
it("moves the selected Foundry auth profile to the front of auth.order", () => {

View File

@@ -51,7 +51,16 @@ async function refreshEntraToken(params?: {
export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthContext) {
if (ctx.apiKey !== "__entra_id_dynamic__") {
return null;
return {
apiKey: ctx.apiKey,
request: {
auth: {
mode: "header" as const,
headerName: ctx.model.api === ANTHROPIC_MESSAGES_API ? "x-api-key" : "api-key",
value: ctx.apiKey,
},
},
};
}
try {
const authStore = ensureAuthProfileStore(ctx.agentDir, {
@@ -102,6 +111,9 @@ export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthC
apiKey: cachedToken.token,
expiresAt: cachedToken.expiresAt,
...(baseUrl ? { baseUrl } : {}),
request: {
auth: { mode: "authorization-bearer" as const, token: cachedToken.token },
},
};
}
let refreshPromise = refreshPromises.get(cacheKey);
@@ -119,6 +131,9 @@ export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthC
return {
...token,
...(baseUrl ? { baseUrl } : {}),
request: {
auth: { mode: "authorization-bearer" as const, token: token.apiKey },
},
};
} catch (err) {
const details = formatErrorMessage(err);

View File

@@ -225,6 +225,13 @@ export function requiresFoundryMandatoryAdaptiveClaudeThinking(value?: string |
: false;
}
function supportsFoundryManualClaudeThinking(value?: string | null): boolean {
const normalized = normalizeFoundryModelName(value)?.replace(/\./g, "-");
return normalized
? /(?:^|-)claude-(?:opus-4-(?:1|5)|sonnet-4-5|haiku-4-5)(?=$|[^a-z0-9])/.test(normalized)
: false;
}
function resolveFoundryModelTokenLimits(value?: string | null): {
contextWindow: number;
maxTokens: number;
@@ -448,6 +455,7 @@ export function resolveFoundryModelCapabilities(
const supportsClaudeThinking =
isAnthropic &&
(supportsClaudeAdaptiveThinking({ id: modelName }) ||
supportsFoundryManualClaudeThinking(modelName) ||
requiresFoundryMandatoryAdaptiveClaudeThinking(modelName));
const supportsClaudeXhighThinking =
isAnthropic && supportsClaudeNativeXhighEffort({ id: modelName });
@@ -502,14 +510,9 @@ function buildFoundryProviderConfig(
modelNameHint?: string | null,
options?: {
api?: FoundryProviderApi;
authMethod?: "api-key" | "entra-id";
apiKey?: SecretInput;
deployments?: FoundryDeploymentConfigInput[];
},
): FoundryProviderConfigPatch {
const runtimeApiKey = options?.authMethod === "api-key" ? options.apiKey : undefined;
const isApiKeyAuth = options?.authMethod === "api-key";
const isEntraIdAuth = options?.authMethod === "entra-id";
const resolvedApi = resolveFoundryApi(modelId, modelNameHint, options?.api);
const deployments = options?.deployments?.length
? options.deployments
@@ -517,16 +520,6 @@ function buildFoundryProviderConfig(
return {
baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, resolvedApi),
api: resolvedApi,
...(isApiKeyAuth
? {
authHeader: false,
...(runtimeApiKey !== undefined
? { apiKey: runtimeApiKey, headers: { "api-key": runtimeApiKey } }
: {}),
}
: isEntraIdAuth
? { authHeader: true, apiKey: undefined, headers: undefined }
: {}),
models: deployments.map((deployment) => {
const capabilities = resolveFoundryModelCapabilities(
deployment.name,
@@ -734,8 +727,6 @@ export function buildFoundryAuthResult(params: {
params.modelNameHint,
{
api: params.api,
authMethod: params.authMethod,
apiKey: params.apiKey,
deployments: params.deployments,
},
) as unknown as ModelProviderConfig,