mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-06 18:12:13 +00:00
fix(foundry): align Claude onboarding contracts
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
buildFoundryConnectionTest,
|
||||
isValidTenantIdentifier,
|
||||
promptApiKeyEndpointAndModel,
|
||||
promptEndpointAndModelManually,
|
||||
selectFoundryDeployment,
|
||||
} from "./onboard.js";
|
||||
import { resetFoundryRuntimeAuthCaches } from "./runtime.js";
|
||||
@@ -20,8 +21,8 @@ import {
|
||||
isAnthropicFoundryDeployment,
|
||||
isFoundryMaiImageModel,
|
||||
normalizeFoundryEndpoint,
|
||||
partitionFoundryDeployments,
|
||||
requiresFoundryMaxCompletionTokens,
|
||||
requiresFoundryEntraIdClaudeAuth,
|
||||
supportsFoundryReasoningContent,
|
||||
supportsFoundryReasoningEffort,
|
||||
supportsFoundryImageInput,
|
||||
@@ -895,6 +896,105 @@ describe("microsoft-foundry plugin", () => {
|
||||
expect(result.defaultModel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps API-key manual setup defaulted to chat completions for GPT deployments", async () => {
|
||||
const text = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("https://example.services.ai.azure.com")
|
||||
.mockResolvedValueOnce("gpt-4o");
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async (params: { initialValue?: string }) => {
|
||||
expect(params.initialValue).toBe("other-chat");
|
||||
return "other-chat";
|
||||
})
|
||||
.mockImplementationOnce(async (params: { initialValue?: string }) => {
|
||||
expect(params.initialValue).toBe("openai-completions");
|
||||
return "openai-completions";
|
||||
});
|
||||
|
||||
const selection = await promptApiKeyEndpointAndModel({
|
||||
prompter: {
|
||||
text,
|
||||
select,
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(selection).toEqual({
|
||||
endpoint: "https://example.services.ai.azure.com",
|
||||
modelId: "gpt-4o",
|
||||
api: "openai-completions",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects Entra-only Claude Mythos deployments during API-key manual setup", async () => {
|
||||
const text = vi.fn(
|
||||
async (params: { message: string; validate?: (value: string) => string | undefined }) => {
|
||||
if (params.message === "Microsoft Foundry endpoint URL") {
|
||||
return "https://example.services.ai.azure.com";
|
||||
}
|
||||
if (params.message === "Default model/deployment name") {
|
||||
return "prod-mythos";
|
||||
}
|
||||
if (params.message === "Claude base model") {
|
||||
expect(params.validate?.("claude-fable-5")).toBeUndefined();
|
||||
expect(params.validate?.("claude-mythos-preview")).toContain("Entra ID auth");
|
||||
return "claude-fable-5";
|
||||
}
|
||||
throw new Error(`unexpected prompt: ${params.message}`);
|
||||
},
|
||||
);
|
||||
const select = vi.fn().mockResolvedValueOnce("claude");
|
||||
|
||||
const selection = await promptApiKeyEndpointAndModel({
|
||||
prompter: {
|
||||
text,
|
||||
select,
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(selection).toEqual({
|
||||
endpoint: "https://example.services.ai.azure.com",
|
||||
modelId: "prod-mythos",
|
||||
modelNameHint: "claude-fable-5",
|
||||
api: "anthropic-messages",
|
||||
});
|
||||
expect(requiresFoundryEntraIdClaudeAuth("claude-mythos-preview")).toBe(true);
|
||||
expect(requiresFoundryEntraIdClaudeAuth("claude-fable-5")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows Entra-only Claude Mythos deployments during Entra manual setup", async () => {
|
||||
const text = vi.fn(
|
||||
async (params: { message: string; validate?: (value: string) => string | undefined }) => {
|
||||
if (params.message === "Microsoft Foundry endpoint URL") {
|
||||
return "https://example.services.ai.azure.com";
|
||||
}
|
||||
if (params.message === "Default model/deployment name") {
|
||||
return "prod-mythos";
|
||||
}
|
||||
if (params.message === "Claude base model") {
|
||||
expect(params.validate?.("claude-mythos-preview")).toBeUndefined();
|
||||
return "claude-mythos-preview";
|
||||
}
|
||||
throw new Error(`unexpected prompt: ${params.message}`);
|
||||
},
|
||||
);
|
||||
const select = vi.fn().mockResolvedValueOnce("claude");
|
||||
|
||||
const selection = await promptEndpointAndModelManually({
|
||||
prompter: {
|
||||
text,
|
||||
select,
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(selection).toEqual({
|
||||
endpoint: "https://example.services.ai.azure.com",
|
||||
modelId: "prod-mythos",
|
||||
modelNameHint: "claude-mythos-preview",
|
||||
api: "anthropic-messages",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses discovered deployment metadata for MAI image defaults", () => {
|
||||
const result = buildFoundryAuthResult({
|
||||
profileId: "microsoft-foundry:entra",
|
||||
@@ -1128,6 +1228,85 @@ describe("microsoft-foundry plugin", () => {
|
||||
expect(provider?.models[0]?.compat?.maxTokensField).toBe("max_tokens");
|
||||
});
|
||||
|
||||
it("routes Claude deployments through Foundry Anthropic Messages", () => {
|
||||
const result = buildFoundryAuthResult({
|
||||
profileId: "microsoft-foundry:entra",
|
||||
apiKey: "__entra_id_dynamic__",
|
||||
endpoint: "https://example.services.ai.azure.com/openai/v1",
|
||||
modelId: "prod-fable",
|
||||
modelNameHint: "claude-fable-5",
|
||||
api: "anthropic-messages",
|
||||
authMethod: "entra-id",
|
||||
});
|
||||
|
||||
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?.models[0]).toMatchObject({
|
||||
id: "prod-fable",
|
||||
name: "claude-fable-5",
|
||||
api: "anthropic-messages",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
||||
});
|
||||
expect(provider?.models[0]?.compat).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"claude-mythos-preview",
|
||||
"claude-fable-5",
|
||||
"claude-opus-4.8",
|
||||
"claude-opus-4.7",
|
||||
"claude-opus-4.6",
|
||||
"claude-sonnet-4.6",
|
||||
])("preserves Foundry Claude 1M token limits for %s", (modelNameHint) => {
|
||||
const result = buildFoundryAuthResult({
|
||||
profileId: "microsoft-foundry:entra",
|
||||
apiKey: "__entra_id_dynamic__",
|
||||
endpoint: "https://example.services.ai.azure.com",
|
||||
modelId: `prod-${modelNameHint.replaceAll(".", "-")}`,
|
||||
modelNameHint,
|
||||
api: "anthropic-messages",
|
||||
authMethod: "entra-id",
|
||||
});
|
||||
|
||||
expect(result.configPatch?.models?.providers?.["microsoft-foundry"]?.models[0]).toMatchObject({
|
||||
name: modelNameHint,
|
||||
api: "anthropic-messages",
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps older Foundry Claude deployments out of Fable-class thinking limits", () => {
|
||||
const result = buildFoundryAuthResult({
|
||||
profileId: "microsoft-foundry:entra",
|
||||
apiKey: "__entra_id_dynamic__",
|
||||
endpoint: "https://example.services.ai.azure.com",
|
||||
modelId: "prod-claude-35",
|
||||
modelNameHint: "claude-3.5-sonnet",
|
||||
api: "anthropic-messages",
|
||||
authMethod: "entra-id",
|
||||
});
|
||||
|
||||
const model = result.configPatch?.models?.providers?.["microsoft-foundry"]?.models[0];
|
||||
expect(model).toMatchObject({
|
||||
id: "prod-claude-35",
|
||||
name: "claude-3.5-sonnet",
|
||||
api: "anthropic-messages",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
});
|
||||
expect(model?.thinkingLevelMap).toBeUndefined();
|
||||
expect(model?.compat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps Foundry chat reasoning_effort enabled for GPT-5 reasoning deployments", () => {
|
||||
const result = buildFoundryAuthResult({
|
||||
profileId: "microsoft-foundry:default",
|
||||
@@ -1366,6 +1545,22 @@ describe("microsoft-foundry plugin", () => {
|
||||
expect(testRequest.body.max_tokens).toBe(1);
|
||||
});
|
||||
|
||||
it("builds Anthropic Messages connection tests for Claude deployments", () => {
|
||||
const testRequest = buildFoundryConnectionTest({
|
||||
endpoint: "https://example.services.ai.azure.com/openai/v1",
|
||||
modelId: "prod-fable",
|
||||
modelNameHint: "claude-fable-5",
|
||||
api: "anthropic-messages",
|
||||
});
|
||||
|
||||
expect(testRequest.url).toBe("https://example.services.ai.azure.com/anthropic/v1/messages");
|
||||
expect(testRequest.body).toEqual({
|
||||
model: "prod-fable",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns actionable Azure CLI login errors", async () => {
|
||||
mockAzureCliLoginFailure();
|
||||
|
||||
@@ -1475,49 +1670,6 @@ describe("microsoft-foundry plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("partitionFoundryDeployments", () => {
|
||||
it("keeps OpenAI-compatible deployments and skips Claude in mixed resources", () => {
|
||||
const { supported, anthropic } = partitionFoundryDeployments([
|
||||
{ name: "prod-gpt", modelName: "gpt-5.4" },
|
||||
{ name: "prod-claude", modelName: "claude-opus-4-6" },
|
||||
{ name: "prod-mini", modelName: "gpt-4o-mini" },
|
||||
]);
|
||||
|
||||
expect(supported.map((deployment) => deployment.name)).toEqual(["prod-gpt", "prod-mini"]);
|
||||
expect(anthropic.map((deployment) => deployment.name)).toEqual(["prod-claude"]);
|
||||
});
|
||||
|
||||
it("returns no supported deployments when only Anthropic deployments exist", () => {
|
||||
const { supported, anthropic } = partitionFoundryDeployments([
|
||||
{ name: "only-claude", modelName: "claude-3.5-sonnet" },
|
||||
]);
|
||||
|
||||
expect(supported).toEqual([]);
|
||||
expect(anthropic.map((deployment) => deployment.name)).toEqual(["only-claude"]);
|
||||
});
|
||||
|
||||
it("is a no-op for all-OpenAI resources", () => {
|
||||
const deployments = [
|
||||
{ name: "prod-gpt", modelName: "gpt-5.4" },
|
||||
{ name: "prod-mini", modelName: "gpt-4o-mini" },
|
||||
];
|
||||
const { supported, anthropic } = partitionFoundryDeployments(deployments);
|
||||
|
||||
expect(supported).toEqual(deployments);
|
||||
expect(anthropic).toEqual([]);
|
||||
});
|
||||
|
||||
it("classifies by deployment name when modelName is missing", () => {
|
||||
const { supported, anthropic } = partitionFoundryDeployments([
|
||||
{ name: "claude-opus-4-6" },
|
||||
{ name: "gpt-5.4-prod" },
|
||||
]);
|
||||
|
||||
expect(supported.map((deployment) => deployment.name)).toEqual(["gpt-5.4-prod"]);
|
||||
expect(anthropic.map((deployment) => deployment.name)).toEqual(["claude-opus-4-6"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectFoundryDeployment", () => {
|
||||
function makeCtx(overrides: { selectValue?: string } = {}) {
|
||||
const noteCalls: Array<{ message: string; title: string }> = [];
|
||||
@@ -1545,7 +1697,7 @@ describe("selectFoundryDeployment", () => {
|
||||
projects: [],
|
||||
};
|
||||
|
||||
it("offers and returns only supported deployments for mixed GPT and Claude resources", async () => {
|
||||
it("offers and returns Claude deployments alongside GPT resources", async () => {
|
||||
const { ctx, selectCalls, noteCalls } = makeCtx({ selectValue: "prod-gpt" });
|
||||
const result = await selectFoundryDeployment(ctx, fakeResource, [
|
||||
{ name: "prod-gpt", modelName: "gpt-5.4", state: "Succeeded" },
|
||||
@@ -1555,25 +1707,30 @@ describe("selectFoundryDeployment", () => {
|
||||
|
||||
expect(result.supported.map((deployment) => deployment.name)).toEqual([
|
||||
"prod-gpt",
|
||||
"prod-claude",
|
||||
"prod-mini",
|
||||
]);
|
||||
expect(result.selected.name).toBe("prod-gpt");
|
||||
expect(selectCalls[0]?.options.map((option) => option.value)).toEqual([
|
||||
"prod-gpt",
|
||||
"prod-claude",
|
||||
"prod-mini",
|
||||
]);
|
||||
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(true);
|
||||
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(false);
|
||||
});
|
||||
|
||||
it("throws an actionable error when only Anthropic deployments exist", async () => {
|
||||
it("uses Anthropic-only deployment resources directly", async () => {
|
||||
const { ctx, noteCalls } = makeCtx();
|
||||
|
||||
await expect(
|
||||
selectFoundryDeployment(ctx, fakeResource, [
|
||||
{ name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" },
|
||||
]),
|
||||
).rejects.toThrow(/Only Anthropic deployments/);
|
||||
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(true);
|
||||
const result = await selectFoundryDeployment(ctx, fakeResource, [
|
||||
{ name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
selected: { name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" },
|
||||
supported: [{ name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" }],
|
||||
});
|
||||
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves all-OpenAI resources unchanged", async () => {
|
||||
|
||||
@@ -22,8 +22,9 @@ import {
|
||||
type FoundrySelection,
|
||||
buildFoundryProviderBaseUrl,
|
||||
extractFoundryEndpoint,
|
||||
partitionFoundryDeployments,
|
||||
requiresFoundryMaxCompletionTokens,
|
||||
requiresFoundryEntraIdClaudeAuth,
|
||||
ANTHROPIC_MESSAGES_API,
|
||||
DEFAULT_API,
|
||||
DEFAULT_GPT5_API,
|
||||
FOUNDRY_ANTHROPIC_SCOPE,
|
||||
@@ -169,33 +170,14 @@ export async function selectFoundryDeployment(
|
||||
resource: FoundryResourceOption,
|
||||
deployments: AzDeploymentSummary[],
|
||||
): Promise<{ selected: AzDeploymentSummary; supported: AzDeploymentSummary[] }> {
|
||||
const { supported, anthropic } = partitionFoundryDeployments(deployments);
|
||||
const anthropicNames = anthropic.map((deployment) => deployment.name);
|
||||
if (anthropicNames.length > 0) {
|
||||
await ctx.prompter.note(
|
||||
[
|
||||
`Skipping ${anthropicNames.length} Anthropic deployment(s) (${anthropicNames.join(", ")}):`,
|
||||
"the built-in Microsoft Foundry provider only supports OpenAI-compatible APIs.",
|
||||
"To use Claude on Azure, configure a custom provider with base URL",
|
||||
"https://<resource>.services.ai.azure.com/anthropic and API: anthropic-messages.",
|
||||
].join(" "),
|
||||
"Unsupported Deployments",
|
||||
);
|
||||
}
|
||||
const supported = deployments;
|
||||
if (supported.length === 0) {
|
||||
const hint =
|
||||
anthropicNames.length > 0
|
||||
? [
|
||||
`Only Anthropic deployments were found in ${resource.accountName},`,
|
||||
"which are not supported by this provider.",
|
||||
"Use a custom provider with the Anthropic Foundry endpoint, or",
|
||||
"deploy an OpenAI-compatible model and rerun onboard.",
|
||||
].join(" ")
|
||||
: [
|
||||
`No model deployments were found in ${resource.accountName}.`,
|
||||
"Deploy a model in Azure AI Foundry or Azure OpenAI, then rerun onboard.",
|
||||
].join("\n");
|
||||
throw new Error(hint);
|
||||
throw new Error(
|
||||
[
|
||||
`No model deployments were found in ${resource.accountName}.`,
|
||||
"Deploy a model in Microsoft Foundry or Azure OpenAI, then rerun onboard.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
if (supported.length === 1) {
|
||||
const only = supported[0];
|
||||
@@ -224,6 +206,11 @@ async function promptFoundryApi(
|
||||
return await ctx.prompter.select({
|
||||
message: "Select request API",
|
||||
options: [
|
||||
{
|
||||
value: ANTHROPIC_MESSAGES_API,
|
||||
label: "Anthropic Messages API",
|
||||
hint: "Use for Claude deployments through Microsoft Foundry /anthropic",
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GPT5_API,
|
||||
label: "Responses API",
|
||||
@@ -239,7 +226,7 @@ async function promptFoundryApi(
|
||||
});
|
||||
}
|
||||
|
||||
type ManualFoundryModelFamilyChoice = "reasoning-family" | "mai-image" | "other-chat";
|
||||
type ManualFoundryModelFamilyChoice = "claude" | "reasoning-family" | "mai-image" | "other-chat";
|
||||
type ManualFoundryMaiImageModel =
|
||||
| "MAI-Image-2.5-Flash"
|
||||
| "MAI-Image-2.5"
|
||||
@@ -248,10 +235,16 @@ type ManualFoundryMaiImageModel =
|
||||
|
||||
async function promptFoundryModelFamily(
|
||||
ctx: ProviderAuthContext,
|
||||
initialValue: ManualFoundryModelFamilyChoice,
|
||||
): Promise<ManualFoundryModelFamilyChoice> {
|
||||
return await ctx.prompter.select({
|
||||
message: "Model family",
|
||||
options: [
|
||||
{
|
||||
value: "claude",
|
||||
label: "Claude",
|
||||
hint: "Use for Anthropic Claude deployments",
|
||||
},
|
||||
{
|
||||
value: "reasoning-family",
|
||||
label: "GPT-5 series / o-series / Codex",
|
||||
@@ -268,7 +261,7 @@ async function promptFoundryModelFamily(
|
||||
hint: "Use for other chat/completions style Foundry models",
|
||||
},
|
||||
],
|
||||
initialValue: "reasoning-family",
|
||||
initialValue,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -303,17 +296,45 @@ async function promptFoundryMaiImageModel(
|
||||
});
|
||||
}
|
||||
|
||||
async function promptFoundryClaudeModel(
|
||||
ctx: ProviderAuthContext,
|
||||
options?: { allowEntraOnlyModels?: boolean },
|
||||
): Promise<string> {
|
||||
return (
|
||||
await ctx.prompter.text({
|
||||
message: "Claude base model",
|
||||
initialValue: "claude-fable-5",
|
||||
placeholder: "claude-fable-5",
|
||||
validate: (v) => {
|
||||
const val = normalizeStringifiedOptionalString(v) ?? "";
|
||||
if (!val) {
|
||||
return "Claude base model is required";
|
||||
}
|
||||
if (!val.toLowerCase().startsWith("claude-")) {
|
||||
return "Use a Claude model name such as claude-fable-5";
|
||||
}
|
||||
if (options?.allowEntraOnlyModels === false && requiresFoundryEntraIdClaudeAuth(val)) {
|
||||
return "Claude Mythos deployments require Microsoft Entra ID auth; choose Entra ID auth or use a Claude model that supports API-key auth.";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
})
|
||||
).trim();
|
||||
}
|
||||
|
||||
async function promptEndpointAndModelBase(
|
||||
ctx: ProviderAuthContext,
|
||||
options?: {
|
||||
endpointInitialValue?: string;
|
||||
modelInitialValue?: string;
|
||||
modelFamilyInitialValue?: ManualFoundryModelFamilyChoice;
|
||||
allowEntraOnlyClaudeModels?: boolean;
|
||||
},
|
||||
): Promise<FoundrySelection> {
|
||||
const endpoint = (
|
||||
await ctx.prompter.text({
|
||||
message: "Microsoft Foundry endpoint URL",
|
||||
placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com",
|
||||
placeholder: "https://xxx.services.ai.azure.com or https://xxx.openai.azure.com",
|
||||
...(options?.endpointInitialValue ? { initialValue: options.endpointInitialValue } : {}),
|
||||
validate: (v) => {
|
||||
const val = normalizeStringifiedOptionalString(v) ?? "";
|
||||
@@ -328,7 +349,7 @@ async function promptEndpointAndModelBase(
|
||||
await ctx.prompter.text({
|
||||
message: "Default model/deployment name",
|
||||
...(options?.modelInitialValue ? { initialValue: options.modelInitialValue } : {}),
|
||||
placeholder: "gpt-4o",
|
||||
placeholder: "claude-fable-5",
|
||||
validate: (v) => {
|
||||
const val = normalizeStringifiedOptionalString(v) ?? "";
|
||||
if (!val) {
|
||||
@@ -338,7 +359,10 @@ async function promptEndpointAndModelBase(
|
||||
},
|
||||
})
|
||||
).trim();
|
||||
const familyChoice = await promptFoundryModelFamily(ctx);
|
||||
const familyChoice = await promptFoundryModelFamily(
|
||||
ctx,
|
||||
options?.modelFamilyInitialValue ?? "claude",
|
||||
);
|
||||
if (familyChoice === "mai-image") {
|
||||
return {
|
||||
endpoint,
|
||||
@@ -347,6 +371,16 @@ async function promptEndpointAndModelBase(
|
||||
api: DEFAULT_API,
|
||||
};
|
||||
}
|
||||
if (familyChoice === "claude") {
|
||||
return {
|
||||
endpoint,
|
||||
modelId,
|
||||
modelNameHint: await promptFoundryClaudeModel(ctx, {
|
||||
allowEntraOnlyModels: options?.allowEntraOnlyClaudeModels ?? true,
|
||||
}),
|
||||
api: ANTHROPIC_MESSAGES_API,
|
||||
};
|
||||
}
|
||||
const resolvedModelName =
|
||||
familyChoice === "reasoning-family"
|
||||
? usesFoundryResponsesByDefault(modelId) || requiresFoundryMaxCompletionTokens(modelId)
|
||||
@@ -377,6 +411,8 @@ export async function promptApiKeyEndpointAndModel(
|
||||
return promptEndpointAndModelBase(ctx, {
|
||||
endpointInitialValue: process.env.AZURE_OPENAI_ENDPOINT,
|
||||
modelInitialValue: "gpt-4o",
|
||||
modelFamilyInitialValue: "other-chat",
|
||||
allowEntraOnlyClaudeModels: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -402,6 +438,16 @@ export function buildFoundryConnectionTest(params: {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (params.api === ANTHROPIC_MESSAGES_API) {
|
||||
return {
|
||||
url: `${baseUrl}/v1/messages`,
|
||||
body: {
|
||||
model: params.modelId,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: `${baseUrl}/chat/completions`,
|
||||
body: {
|
||||
@@ -547,6 +593,7 @@ export async function testFoundryConnection(params: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(params.api === ANTHROPIC_MESSAGES_API ? { "anthropic-version": "2023-06-01" } : {}),
|
||||
},
|
||||
body: JSON.stringify(testRequest.body),
|
||||
},
|
||||
|
||||
@@ -6,7 +6,13 @@ import {
|
||||
type ProviderAuthResult,
|
||||
type SecretInput,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import type { ModelApi, ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
isClaudeMandatoryAdaptiveThinkingModelId,
|
||||
supportsClaudeAdaptiveThinking,
|
||||
supportsClaudeNativeXhighEffort,
|
||||
type ModelApi,
|
||||
type ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
@@ -15,6 +21,7 @@ import {
|
||||
export const PROVIDER_ID = "microsoft-foundry";
|
||||
export const DEFAULT_API = "openai-completions";
|
||||
export const DEFAULT_GPT5_API = "openai-responses";
|
||||
export const ANTHROPIC_MESSAGES_API = "anthropic-messages";
|
||||
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;
|
||||
@@ -81,7 +88,10 @@ export type CachedTokenEntry = {
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API;
|
||||
export type FoundryProviderApi =
|
||||
| typeof DEFAULT_API
|
||||
| typeof DEFAULT_GPT5_API
|
||||
| typeof ANTHROPIC_MESSAGES_API;
|
||||
|
||||
type FoundryDeploymentConfigInput = {
|
||||
name: string;
|
||||
@@ -141,22 +151,6 @@ export function isAnthropicFoundryDeployment(modelName?: string | null): boolean
|
||||
return normalized ? normalized.startsWith("claude") : false;
|
||||
}
|
||||
|
||||
export function partitionFoundryDeployments<T extends { name: string; modelName?: string }>(
|
||||
deployments: readonly T[],
|
||||
): { supported: T[]; anthropic: T[] } {
|
||||
const supported: T[] = [];
|
||||
const anthropic: T[] = [];
|
||||
for (const deployment of deployments) {
|
||||
const classifier = resolveConfiguredModelNameHint(deployment.name, deployment.modelName);
|
||||
if (isAnthropicFoundryDeployment(classifier)) {
|
||||
anthropic.push(deployment);
|
||||
} else {
|
||||
supported.push(deployment);
|
||||
}
|
||||
}
|
||||
return { supported, anthropic };
|
||||
}
|
||||
|
||||
export function usesFoundryResponsesByDefault(value?: string | null): boolean {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
if (!normalized) {
|
||||
@@ -197,6 +191,7 @@ export function supportsFoundryImageInput(value?: string | null): boolean {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isAnthropicFoundryDeployment(normalized) ||
|
||||
normalized.startsWith("gpt-") ||
|
||||
normalized.startsWith("o1") ||
|
||||
normalized.startsWith("o3") ||
|
||||
@@ -205,11 +200,21 @@ export function supportsFoundryImageInput(value?: string | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function requiresFoundryEntraIdClaudeAuth(value?: string | null): boolean {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
return normalized
|
||||
? normalized === "claude-mythos-preview" || normalized.startsWith("claude-mythos-")
|
||||
: false;
|
||||
}
|
||||
|
||||
function resolveFoundryModelTokenLimits(value?: string | null): {
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
} {
|
||||
const normalized = normalizeFoundryModelName(value);
|
||||
if (normalized && supportsClaudeAdaptiveThinking(normalized)) {
|
||||
return { contextWindow: 1_000_000, maxTokens: 128_000 };
|
||||
}
|
||||
if (normalized === "mai-ds-r1") {
|
||||
return { contextWindow: 163_840, maxTokens: 163_840 };
|
||||
}
|
||||
@@ -291,7 +296,7 @@ function buildFoundryThinkingLevelMap(
|
||||
}
|
||||
|
||||
export function isFoundryProviderApi(value?: string | null): value is FoundryProviderApi {
|
||||
return value === DEFAULT_API || value === DEFAULT_GPT5_API;
|
||||
return value === DEFAULT_API || value === DEFAULT_GPT5_API || value === ANTHROPIC_MESSAGES_API;
|
||||
}
|
||||
|
||||
export function normalizeFoundryEndpoint(endpoint: string): string {
|
||||
@@ -303,11 +308,13 @@ export function normalizeFoundryEndpoint(endpoint: string): string {
|
||||
const parsed = new URL(trimmed);
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
const normalizedPath = parsed.pathname.replace(/\/openai(?:$|\/).*/i, "").replace(/\/+$/, "");
|
||||
const normalizedPath = parsed.pathname
|
||||
.replace(/\/(?:openai|anthropic)(?:$|\/).*/i, "")
|
||||
.replace(/\/+$/, "");
|
||||
return `${parsed.origin}${normalizedPath && normalizedPath !== "/" ? normalizedPath : ""}`;
|
||||
} catch {
|
||||
const withoutQuery = trimmed.replace(/[?#].*$/, "").replace(/\/+$/, "");
|
||||
return withoutQuery.replace(/\/openai(?:$|\/).*/i, "");
|
||||
return withoutQuery.replace(/\/(?:openai|anthropic)(?:$|\/).*/i, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +323,11 @@ function buildFoundryV1BaseUrl(endpoint: string): string {
|
||||
return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`;
|
||||
}
|
||||
|
||||
function buildFoundryAnthropicBaseUrl(endpoint: string): string {
|
||||
const base = normalizeFoundryEndpoint(endpoint);
|
||||
return base.endsWith("/anthropic") ? base : `${base}/anthropic`;
|
||||
}
|
||||
|
||||
export function resolveFoundryApi(
|
||||
modelId: string,
|
||||
modelNameHint?: string | null,
|
||||
@@ -325,16 +337,22 @@ export function resolveFoundryApi(
|
||||
return configuredApi;
|
||||
}
|
||||
const configuredModelName = resolveConfiguredModelNameHint(modelId, modelNameHint);
|
||||
if (isAnthropicFoundryDeployment(configuredModelName)) {
|
||||
return ANTHROPIC_MESSAGES_API;
|
||||
}
|
||||
return usesFoundryResponsesByDefault(configuredModelName) ? DEFAULT_GPT5_API : DEFAULT_API;
|
||||
}
|
||||
|
||||
export function buildFoundryProviderBaseUrl(
|
||||
endpoint: string,
|
||||
_modelId: string,
|
||||
_modelNameHint?: string | null,
|
||||
_configuredApi?: ModelApi | null,
|
||||
modelId: string,
|
||||
modelNameHint?: string | null,
|
||||
configuredApi?: ModelApi | null,
|
||||
): string {
|
||||
return buildFoundryV1BaseUrl(endpoint);
|
||||
const resolvedApi = resolveFoundryApi(modelId, modelNameHint, configuredApi);
|
||||
return resolvedApi === ANTHROPIC_MESSAGES_API
|
||||
? buildFoundryAnthropicBaseUrl(endpoint)
|
||||
: buildFoundryV1BaseUrl(endpoint);
|
||||
}
|
||||
|
||||
export function extractFoundryEndpoint(baseUrl: string | null | undefined): string | undefined {
|
||||
@@ -354,6 +372,9 @@ function buildFoundryModelCompat(
|
||||
configuredApi?: ModelApi | null,
|
||||
): FoundryModelCompat | undefined {
|
||||
const resolvedApi = resolveFoundryApi(modelId, modelNameHint, configuredApi);
|
||||
if (resolvedApi === ANTHROPIC_MESSAGES_API) {
|
||||
return undefined;
|
||||
}
|
||||
const configuredModelName = resolveConfiguredModelNameHint(modelId, modelNameHint);
|
||||
const needsMaxCompletionTokens = requiresFoundryMaxCompletionTokens(configuredModelName);
|
||||
const supportsReasoningEffort = supportsFoundryReasoningEffort(configuredModelName);
|
||||
@@ -382,15 +403,22 @@ export function resolveFoundryModelCapabilities(
|
||||
const api = resolveFoundryApi(modelId, modelName, configuredApi);
|
||||
const normalizedInput = normalizeModelInput(existingInput);
|
||||
const supportedReasoningEfforts = resolveFoundryReasoningEfforts(modelName);
|
||||
const isAnthropic = api === ANTHROPIC_MESSAGES_API || isAnthropicFoundryDeployment(modelName);
|
||||
const supportsClaudeThinking = isAnthropic && supportsClaudeAdaptiveThinking(modelName);
|
||||
const supportsClaudeXhighThinking = isAnthropic && supportsClaudeNativeXhighEffort(modelName);
|
||||
const tokenLimits = resolveFoundryModelTokenLimits(modelName);
|
||||
return {
|
||||
modelName,
|
||||
api,
|
||||
reasoning:
|
||||
supportsFoundryReasoningEffort(modelName) || supportsFoundryReasoningContent(modelName),
|
||||
...(supportedReasoningEfforts
|
||||
? { thinkingLevelMap: buildFoundryThinkingLevelMap(supportedReasoningEfforts) }
|
||||
: {}),
|
||||
supportsClaudeThinking ||
|
||||
supportsFoundryReasoningEffort(modelName) ||
|
||||
supportsFoundryReasoningContent(modelName),
|
||||
...(supportsClaudeXhighThinking
|
||||
? { thinkingLevelMap: { xhigh: "xhigh", max: "max" } }
|
||||
: supportedReasoningEfforts
|
||||
? { thinkingLevelMap: buildFoundryThinkingLevelMap(supportedReasoningEfforts) }
|
||||
: {}),
|
||||
input:
|
||||
normalizedInput.includes("image") || supportsFoundryImageInput(modelName)
|
||||
? ["text", "image"]
|
||||
@@ -426,6 +454,7 @@ function buildFoundryProviderConfig(
|
||||
): ModelProviderConfig {
|
||||
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
|
||||
@@ -440,7 +469,9 @@ function buildFoundryProviderConfig(
|
||||
? { apiKey: runtimeApiKey, headers: { "api-key": runtimeApiKey } }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
: isEntraIdAuth
|
||||
? { authHeader: true }
|
||||
: {}),
|
||||
models: deployments.map((deployment) => {
|
||||
const capabilities = resolveFoundryModelCapabilities(
|
||||
deployment.name,
|
||||
|
||||
Reference in New Issue
Block a user