Skip to content

Commit 0a8dfc2

Browse files
committed
fix(foundry): align Claude onboarding contracts
1 parent 378ae44 commit 0a8dfc2

3 files changed

Lines changed: 349 additions & 114 deletions

File tree

extensions/microsoft-foundry/index.test.ts

Lines changed: 210 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
buildFoundryConnectionTest,
1111
isValidTenantIdentifier,
1212
promptApiKeyEndpointAndModel,
13+
promptEndpointAndModelManually,
1314
selectFoundryDeployment,
1415
} from "./onboard.js";
1516
import { resetFoundryRuntimeAuthCaches } from "./runtime.js";
@@ -20,8 +21,8 @@ import {
2021
isAnthropicFoundryDeployment,
2122
isFoundryMaiImageModel,
2223
normalizeFoundryEndpoint,
23-
partitionFoundryDeployments,
2424
requiresFoundryMaxCompletionTokens,
25+
requiresFoundryEntraIdClaudeAuth,
2526
supportsFoundryReasoningContent,
2627
supportsFoundryReasoningEffort,
2728
supportsFoundryImageInput,
@@ -895,6 +896,105 @@ describe("microsoft-foundry plugin", () => {
895896
expect(result.defaultModel).toBeUndefined();
896897
});
897898

899+
it("keeps API-key manual setup defaulted to chat completions for GPT deployments", async () => {
900+
const text = vi
901+
.fn()
902+
.mockResolvedValueOnce("https://example.services.ai.azure.com")
903+
.mockResolvedValueOnce("gpt-4o");
904+
const select = vi
905+
.fn()
906+
.mockImplementationOnce(async (params: { initialValue?: string }) => {
907+
expect(params.initialValue).toBe("other-chat");
908+
return "other-chat";
909+
})
910+
.mockImplementationOnce(async (params: { initialValue?: string }) => {
911+
expect(params.initialValue).toBe("openai-completions");
912+
return "openai-completions";
913+
});
914+
915+
const selection = await promptApiKeyEndpointAndModel({
916+
prompter: {
917+
text,
918+
select,
919+
},
920+
} as never);
921+
922+
expect(selection).toEqual({
923+
endpoint: "https://example.services.ai.azure.com",
924+
modelId: "gpt-4o",
925+
api: "openai-completions",
926+
});
927+
});
928+
929+
it("rejects Entra-only Claude Mythos deployments during API-key manual setup", async () => {
930+
const text = vi.fn(
931+
async (params: { message: string; validate?: (value: string) => string | undefined }) => {
932+
if (params.message === "Microsoft Foundry endpoint URL") {
933+
return "https://example.services.ai.azure.com";
934+
}
935+
if (params.message === "Default model/deployment name") {
936+
return "prod-mythos";
937+
}
938+
if (params.message === "Claude base model") {
939+
expect(params.validate?.("claude-fable-5")).toBeUndefined();
940+
expect(params.validate?.("claude-mythos-preview")).toContain("Entra ID auth");
941+
return "claude-fable-5";
942+
}
943+
throw new Error(`unexpected prompt: ${params.message}`);
944+
},
945+
);
946+
const select = vi.fn().mockResolvedValueOnce("claude");
947+
948+
const selection = await promptApiKeyEndpointAndModel({
949+
prompter: {
950+
text,
951+
select,
952+
},
953+
} as never);
954+
955+
expect(selection).toEqual({
956+
endpoint: "https://example.services.ai.azure.com",
957+
modelId: "prod-mythos",
958+
modelNameHint: "claude-fable-5",
959+
api: "anthropic-messages",
960+
});
961+
expect(requiresFoundryEntraIdClaudeAuth("claude-mythos-preview")).toBe(true);
962+
expect(requiresFoundryEntraIdClaudeAuth("claude-fable-5")).toBe(false);
963+
});
964+
965+
it("allows Entra-only Claude Mythos deployments during Entra manual setup", async () => {
966+
const text = vi.fn(
967+
async (params: { message: string; validate?: (value: string) => string | undefined }) => {
968+
if (params.message === "Microsoft Foundry endpoint URL") {
969+
return "https://example.services.ai.azure.com";
970+
}
971+
if (params.message === "Default model/deployment name") {
972+
return "prod-mythos";
973+
}
974+
if (params.message === "Claude base model") {
975+
expect(params.validate?.("claude-mythos-preview")).toBeUndefined();
976+
return "claude-mythos-preview";
977+
}
978+
throw new Error(`unexpected prompt: ${params.message}`);
979+
},
980+
);
981+
const select = vi.fn().mockResolvedValueOnce("claude");
982+
983+
const selection = await promptEndpointAndModelManually({
984+
prompter: {
985+
text,
986+
select,
987+
},
988+
} as never);
989+
990+
expect(selection).toEqual({
991+
endpoint: "https://example.services.ai.azure.com",
992+
modelId: "prod-mythos",
993+
modelNameHint: "claude-mythos-preview",
994+
api: "anthropic-messages",
995+
});
996+
});
997+
898998
it("uses discovered deployment metadata for MAI image defaults", () => {
899999
const result = buildFoundryAuthResult({
9001000
profileId: "microsoft-foundry:entra",
@@ -1128,6 +1228,85 @@ describe("microsoft-foundry plugin", () => {
11281228
expect(provider?.models[0]?.compat?.maxTokensField).toBe("max_tokens");
11291229
});
11301230

1231+
it("routes Claude deployments through Foundry Anthropic Messages", () => {
1232+
const result = buildFoundryAuthResult({
1233+
profileId: "microsoft-foundry:entra",
1234+
apiKey: "__entra_id_dynamic__",
1235+
endpoint: "https://example.services.ai.azure.com/openai/v1",
1236+
modelId: "prod-fable",
1237+
modelNameHint: "claude-fable-5",
1238+
api: "anthropic-messages",
1239+
authMethod: "entra-id",
1240+
});
1241+
1242+
const provider = result.configPatch?.models?.providers?.["microsoft-foundry"];
1243+
expect(provider?.baseUrl).toBe("https://example.services.ai.azure.com/anthropic");
1244+
expect(provider?.api).toBe("anthropic-messages");
1245+
expect(provider?.authHeader).toBe(true);
1246+
expect(provider?.models[0]).toMatchObject({
1247+
id: "prod-fable",
1248+
name: "claude-fable-5",
1249+
api: "anthropic-messages",
1250+
reasoning: true,
1251+
input: ["text", "image"],
1252+
contextWindow: 1_000_000,
1253+
maxTokens: 128_000,
1254+
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
1255+
});
1256+
expect(provider?.models[0]?.compat).toBeUndefined();
1257+
});
1258+
1259+
it.each([
1260+
"claude-mythos-preview",
1261+
"claude-fable-5",
1262+
"claude-opus-4.8",
1263+
"claude-opus-4.7",
1264+
"claude-opus-4.6",
1265+
"claude-sonnet-4.6",
1266+
])("preserves Foundry Claude 1M token limits for %s", (modelNameHint) => {
1267+
const result = buildFoundryAuthResult({
1268+
profileId: "microsoft-foundry:entra",
1269+
apiKey: "__entra_id_dynamic__",
1270+
endpoint: "https://example.services.ai.azure.com",
1271+
modelId: `prod-${modelNameHint.replaceAll(".", "-")}`,
1272+
modelNameHint,
1273+
api: "anthropic-messages",
1274+
authMethod: "entra-id",
1275+
});
1276+
1277+
expect(result.configPatch?.models?.providers?.["microsoft-foundry"]?.models[0]).toMatchObject({
1278+
name: modelNameHint,
1279+
api: "anthropic-messages",
1280+
contextWindow: 1_000_000,
1281+
maxTokens: 128_000,
1282+
});
1283+
});
1284+
1285+
it("keeps older Foundry Claude deployments out of Fable-class thinking limits", () => {
1286+
const result = buildFoundryAuthResult({
1287+
profileId: "microsoft-foundry:entra",
1288+
apiKey: "__entra_id_dynamic__",
1289+
endpoint: "https://example.services.ai.azure.com",
1290+
modelId: "prod-claude-35",
1291+
modelNameHint: "claude-3.5-sonnet",
1292+
api: "anthropic-messages",
1293+
authMethod: "entra-id",
1294+
});
1295+
1296+
const model = result.configPatch?.models?.providers?.["microsoft-foundry"]?.models[0];
1297+
expect(model).toMatchObject({
1298+
id: "prod-claude-35",
1299+
name: "claude-3.5-sonnet",
1300+
api: "anthropic-messages",
1301+
reasoning: false,
1302+
input: ["text", "image"],
1303+
contextWindow: 128_000,
1304+
maxTokens: 16_384,
1305+
});
1306+
expect(model?.thinkingLevelMap).toBeUndefined();
1307+
expect(model?.compat).toBeUndefined();
1308+
});
1309+
11311310
it("keeps Foundry chat reasoning_effort enabled for GPT-5 reasoning deployments", () => {
11321311
const result = buildFoundryAuthResult({
11331312
profileId: "microsoft-foundry:default",
@@ -1366,6 +1545,22 @@ describe("microsoft-foundry plugin", () => {
13661545
expect(testRequest.body.max_tokens).toBe(1);
13671546
});
13681547

1548+
it("builds Anthropic Messages connection tests for Claude deployments", () => {
1549+
const testRequest = buildFoundryConnectionTest({
1550+
endpoint: "https://example.services.ai.azure.com/openai/v1",
1551+
modelId: "prod-fable",
1552+
modelNameHint: "claude-fable-5",
1553+
api: "anthropic-messages",
1554+
});
1555+
1556+
expect(testRequest.url).toBe("https://example.services.ai.azure.com/anthropic/v1/messages");
1557+
expect(testRequest.body).toEqual({
1558+
model: "prod-fable",
1559+
messages: [{ role: "user", content: "hi" }],
1560+
max_tokens: 1,
1561+
});
1562+
});
1563+
13691564
it("returns actionable Azure CLI login errors", async () => {
13701565
mockAzureCliLoginFailure();
13711566

@@ -1475,49 +1670,6 @@ describe("microsoft-foundry plugin", () => {
14751670
});
14761671
});
14771672

1478-
describe("partitionFoundryDeployments", () => {
1479-
it("keeps OpenAI-compatible deployments and skips Claude in mixed resources", () => {
1480-
const { supported, anthropic } = partitionFoundryDeployments([
1481-
{ name: "prod-gpt", modelName: "gpt-5.4" },
1482-
{ name: "prod-claude", modelName: "claude-opus-4-6" },
1483-
{ name: "prod-mini", modelName: "gpt-4o-mini" },
1484-
]);
1485-
1486-
expect(supported.map((deployment) => deployment.name)).toEqual(["prod-gpt", "prod-mini"]);
1487-
expect(anthropic.map((deployment) => deployment.name)).toEqual(["prod-claude"]);
1488-
});
1489-
1490-
it("returns no supported deployments when only Anthropic deployments exist", () => {
1491-
const { supported, anthropic } = partitionFoundryDeployments([
1492-
{ name: "only-claude", modelName: "claude-3.5-sonnet" },
1493-
]);
1494-
1495-
expect(supported).toEqual([]);
1496-
expect(anthropic.map((deployment) => deployment.name)).toEqual(["only-claude"]);
1497-
});
1498-
1499-
it("is a no-op for all-OpenAI resources", () => {
1500-
const deployments = [
1501-
{ name: "prod-gpt", modelName: "gpt-5.4" },
1502-
{ name: "prod-mini", modelName: "gpt-4o-mini" },
1503-
];
1504-
const { supported, anthropic } = partitionFoundryDeployments(deployments);
1505-
1506-
expect(supported).toEqual(deployments);
1507-
expect(anthropic).toEqual([]);
1508-
});
1509-
1510-
it("classifies by deployment name when modelName is missing", () => {
1511-
const { supported, anthropic } = partitionFoundryDeployments([
1512-
{ name: "claude-opus-4-6" },
1513-
{ name: "gpt-5.4-prod" },
1514-
]);
1515-
1516-
expect(supported.map((deployment) => deployment.name)).toEqual(["gpt-5.4-prod"]);
1517-
expect(anthropic.map((deployment) => deployment.name)).toEqual(["claude-opus-4-6"]);
1518-
});
1519-
});
1520-
15211673
describe("selectFoundryDeployment", () => {
15221674
function makeCtx(overrides: { selectValue?: string } = {}) {
15231675
const noteCalls: Array<{ message: string; title: string }> = [];
@@ -1545,7 +1697,7 @@ describe("selectFoundryDeployment", () => {
15451697
projects: [],
15461698
};
15471699

1548-
it("offers and returns only supported deployments for mixed GPT and Claude resources", async () => {
1700+
it("offers and returns Claude deployments alongside GPT resources", async () => {
15491701
const { ctx, selectCalls, noteCalls } = makeCtx({ selectValue: "prod-gpt" });
15501702
const result = await selectFoundryDeployment(ctx, fakeResource, [
15511703
{ name: "prod-gpt", modelName: "gpt-5.4", state: "Succeeded" },
@@ -1555,25 +1707,30 @@ describe("selectFoundryDeployment", () => {
15551707

15561708
expect(result.supported.map((deployment) => deployment.name)).toEqual([
15571709
"prod-gpt",
1710+
"prod-claude",
15581711
"prod-mini",
15591712
]);
15601713
expect(result.selected.name).toBe("prod-gpt");
15611714
expect(selectCalls[0]?.options.map((option) => option.value)).toEqual([
15621715
"prod-gpt",
1716+
"prod-claude",
15631717
"prod-mini",
15641718
]);
1565-
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(true);
1719+
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(false);
15661720
});
15671721

1568-
it("throws an actionable error when only Anthropic deployments exist", async () => {
1722+
it("uses Anthropic-only deployment resources directly", async () => {
15691723
const { ctx, noteCalls } = makeCtx();
15701724

1571-
await expect(
1572-
selectFoundryDeployment(ctx, fakeResource, [
1573-
{ name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" },
1574-
]),
1575-
).rejects.toThrow(/Only Anthropic deployments/);
1576-
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(true);
1725+
const result = await selectFoundryDeployment(ctx, fakeResource, [
1726+
{ name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" },
1727+
]);
1728+
1729+
expect(result).toEqual({
1730+
selected: { name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" },
1731+
supported: [{ name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" }],
1732+
});
1733+
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(false);
15771734
});
15781735

15791736
it("leaves all-OpenAI resources unchanged", async () => {

0 commit comments

Comments
 (0)