Skip to content

Commit 9b06ad4

Browse files
committed
feat: add Azure Claude (AI Foundry) onboarding path
Add Azure Claude as a provider option in onboarding, supporting: - Interactive and non-interactive auth flows with API key, base URL, and model selection - Resource name validation that rejects invalid characters with clear error messages - Lazy-loaded Azure utils to avoid startup memory impact - CLI flags: --anthropic-azure-api-key, --anthropic-azure-base-url, --anthropic-azure-model-id - Environment variable support: ANTHROPIC_FOUNDRY_API_KEY, AZURE_CLAUDE_API_KEY, ANTHROPIC_FOUNDRY_BASE_URL, AZURE_CLAUDE_BASE_URL, ANTHROPIC_FOUNDRY_RESOURCE, AZURE_CLAUDE_RESOURCE Also fixes ChannelMessageCapability import in contracts/suites.ts (pre-existing upstream type error).
1 parent 94a01c9 commit 9b06ad4

15 files changed

Lines changed: 569 additions & 2 deletions

docs/providers/anthropic.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,43 @@ Note: Anthropic currently rejects `context-1m-*` beta requests when using
186186
OAuth/subscription tokens (`sk-ant-oat-*`). OpenClaw automatically skips the
187187
context1m beta header for OAuth auth and keeps the required OAuth betas.
188188

189-
## Option B: Claude setup-token
189+
## Option B: Azure Claude (Azure AI Foundry)
190+
191+
**Best for:** running Claude Sonnet/Opus deployments that live behind Azure AI Foundry.
192+
193+
During onboarding pick **Azure Claude (AI Foundry)** when the auth screen appears, then provide:
194+
195+
1. **Resource name or base URL** – either the bare resource (`fabric-hub`) or the full
196+
`https://fabric-hub.services.ai.azure.com/anthropic` endpoint.
197+
2. **Default deployment** – choose one of the pre-populated IDs (`claude-sonnet-4-6`,
198+
`claude-opus-4-6`) or enter any custom deployment you created in AI Studio.
199+
3. **API key** – paste `ANTHROPIC_FOUNDRY_API_KEY` / `AZURE_CLAUDE_API_KEY` from the
200+
Azure portal (AI Foundry → Claude connection → _Keys & Endpoint_).
201+
202+
### Azure CLI setup
203+
204+
```bash
205+
# Interactive
206+
openclaw onboard --auth-choice anthropic-azure-api-key
207+
208+
# Non-interactive example
209+
openclaw onboard --non-interactive --accept-risk \
210+
--auth-choice anthropic-azure-api-key \
211+
--anthropic-azure-base-url fabric-hub \
212+
--anthropic-azure-model-id claude-sonnet-4-6 \
213+
--anthropic-azure-api-key "$ANTHROPIC_FOUNDRY_API_KEY"
214+
```
215+
216+
Supported env vars (picked up automatically when set):
217+
218+
- `ANTHROPIC_FOUNDRY_BASE_URL` / `AZURE_CLAUDE_BASE_URL`
219+
- `ANTHROPIC_FOUNDRY_RESOURCE` / `AZURE_CLAUDE_RESOURCE`
220+
- `ANTHROPIC_FOUNDRY_API_KEY` / `AZURE_CLAUDE_API_KEY`
221+
222+
OpenClaw stores the base URL, resource, and deployment metadata with the
223+
`anthropic-azure:default` auth profile so future doctor runs can surface it.
224+
225+
## Option C: Claude setup-token
190226

191227
**Best for:** using your Claude subscription.
192228

src/cli/program/register.onboard.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,15 @@ export function registerOnboardCommand(program: Command) {
106106
}
107107

108108
command
109+
.option("--anthropic-azure-api-key <key>", "Azure Claude API key (AI Foundry)")
110+
.option(
111+
"--anthropic-azure-base-url <resource-or-url>",
112+
"Azure AI Foundry resource name or full base URL (https://<resource>.services.ai.azure.com/anthropic)",
113+
)
114+
.option(
115+
"--anthropic-azure-model-id <deployment>",
116+
"Azure Claude deployment/model ID (default: claude-sonnet-4-6)",
117+
)
109118
.option("--custom-base-url <url>", "Custom provider base URL")
110119
.option("--custom-api-key <key>", "Custom provider API key (optional)")
111120
.option("--custom-model-id <id>", "Custom provider model ID")
@@ -163,6 +172,9 @@ export function registerOnboardCommand(program: Command) {
163172
tokenExpiresIn: opts.tokenExpiresIn as string | undefined,
164173
secretInputMode: opts.secretInputMode as SecretInputMode | undefined,
165174
...providerAuthOptionValues,
175+
anthropicAzureApiKey: opts.anthropicAzureApiKey as string | undefined,
176+
anthropicAzureBaseUrl: opts.anthropicAzureBaseUrl as string | undefined,
177+
anthropicAzureModelId: opts.anthropicAzureModelId as string | undefined,
166178
cloudflareAiGatewayAccountId: opts.cloudflareAiGatewayAccountId as string | undefined,
167179
cloudflareAiGatewayGatewayId: opts.cloudflareAiGatewayGatewayId as string | undefined,
168180
customBaseUrl: opts.customBaseUrl as string | undefined,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
DEFAULT_ANTHROPIC_AZURE_MODEL_ID,
4+
buildAnthropicAzureModelDefinition,
5+
normalizeAnthropicAzureBaseUrl,
6+
resolveAnthropicAzureBaseUrlFromEnv,
7+
resolveAnthropicAzureResourceName,
8+
} from "./anthropic-azure-utils.js";
9+
10+
describe("anthropic-azure-utils", () => {
11+
it("normalizes resource names into Azure base URLs", () => {
12+
expect(normalizeAnthropicAzureBaseUrl("Fabric-Hub")).toBe(
13+
"https://fabric-hub.services.ai.azure.com/anthropic",
14+
);
15+
});
16+
17+
it("rejects resource names with invalid characters instead of silently stripping", () => {
18+
expect(() => normalizeAnthropicAzureBaseUrl("fabric_hub")).toThrow(
19+
/contains invalid characters/,
20+
);
21+
expect(() => normalizeAnthropicAzureBaseUrl("my resource")).toThrow(
22+
/contains invalid characters/,
23+
);
24+
});
25+
26+
it("normalizes existing base URLs and enforces suffix", () => {
27+
expect(
28+
normalizeAnthropicAzureBaseUrl("https://fabric-hub.services.ai.azure.com/anthropic/"),
29+
).toBe("https://fabric-hub.services.ai.azure.com/anthropic");
30+
});
31+
32+
it("derives resource name from normalized URL", () => {
33+
expect(
34+
resolveAnthropicAzureResourceName("https://fabric-hub.services.ai.azure.com/anthropic"),
35+
).toBe("fabric-hub");
36+
});
37+
38+
it("resolves env vars preferring base URLs over resources", () => {
39+
const env = {
40+
ANTHROPIC_FOUNDRY_BASE_URL: "https://env-base.services.ai.azure.com/anthropic",
41+
ANTHROPIC_FOUNDRY_RESOURCE: "ignored-resource",
42+
} as NodeJS.ProcessEnv;
43+
expect(resolveAnthropicAzureBaseUrlFromEnv(env)).toBe(
44+
"https://env-base.services.ai.azure.com/anthropic",
45+
);
46+
});
47+
48+
it("falls back to resource env vars when no base URL provided", () => {
49+
const env = {
50+
ANTHROPIC_FOUNDRY_RESOURCE: "fallback-resource",
51+
} as NodeJS.ProcessEnv;
52+
expect(resolveAnthropicAzureBaseUrlFromEnv(env)).toBe(
53+
"https://fallback-resource.services.ai.azure.com/anthropic",
54+
);
55+
});
56+
57+
it("builds model definitions with defaults", () => {
58+
const model = buildAnthropicAzureModelDefinition({ id: DEFAULT_ANTHROPIC_AZURE_MODEL_ID });
59+
expect(model.id).toBe(DEFAULT_ANTHROPIC_AZURE_MODEL_ID);
60+
expect(model.input).toContain("text");
61+
});
62+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { URL } from "node:url";
2+
import type { ModelDefinitionConfig } from "../config/types.models.js";
3+
4+
export const ANTHROPIC_AZURE_HOST_SUFFIX = ".services.ai.azure.com";
5+
export const ANTHROPIC_AZURE_API_SUFFIX = "/anthropic";
6+
7+
export const DEFAULT_ANTHROPIC_AZURE_MODEL_ID = "claude-sonnet-4-6";
8+
9+
export const ANTHROPIC_AZURE_MODEL_CHOICES = [
10+
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (default)" },
11+
{ value: "claude-opus-4-6", label: "Claude Opus 4.6" },
12+
] as const;
13+
14+
const ANTHROPIC_AZURE_DEFAULT_CONTEXT_WINDOW = 200_000;
15+
const ANTHROPIC_AZURE_DEFAULT_MAX_TOKENS = 16_384;
16+
const ANTHROPIC_AZURE_DEFAULT_COST = {
17+
input: 0,
18+
output: 0,
19+
cacheRead: 0,
20+
cacheWrite: 0,
21+
};
22+
23+
export function buildAnthropicAzureModelDefinition(params: {
24+
id: string;
25+
label?: string;
26+
}): ModelDefinitionConfig {
27+
return {
28+
id: params.id,
29+
name: params.label ?? params.id,
30+
reasoning: true,
31+
input: ["text", "image"],
32+
cost: ANTHROPIC_AZURE_DEFAULT_COST,
33+
contextWindow: ANTHROPIC_AZURE_DEFAULT_CONTEXT_WINDOW,
34+
maxTokens: ANTHROPIC_AZURE_DEFAULT_MAX_TOKENS,
35+
};
36+
}
37+
38+
function ensureHttpsUrl(candidate: string): URL {
39+
try {
40+
return new URL(candidate);
41+
} catch (error) {
42+
throw new Error(`Invalid Azure Claude URL: ${String(error)}`, { cause: error });
43+
}
44+
}
45+
46+
export function normalizeAnthropicAzureBaseUrl(resourceOrUrl: string): string {
47+
const raw = String(resourceOrUrl ?? "").trim();
48+
if (!raw) {
49+
throw new Error("Azure Claude resource name or URL is required.");
50+
}
51+
if (/^https?:\/\//i.test(raw)) {
52+
const url = ensureHttpsUrl(raw);
53+
if (!url.hostname.toLowerCase().endsWith(ANTHROPIC_AZURE_HOST_SUFFIX)) {
54+
throw new Error(
55+
`Azure Claude endpoint host must end with "${ANTHROPIC_AZURE_HOST_SUFFIX}". Received ${url.hostname}.`,
56+
);
57+
}
58+
const normalizedHost = url.hostname.toLowerCase();
59+
const basePath = url.pathname.replace(/\/+$/, "");
60+
const normalizedPath = basePath.endsWith(ANTHROPIC_AZURE_API_SUFFIX)
61+
? basePath
62+
: `${basePath}${ANTHROPIC_AZURE_API_SUFFIX}`;
63+
const finalPath = normalizedPath || ANTHROPIC_AZURE_API_SUFFIX;
64+
return `https://${normalizedHost}${finalPath}`;
65+
}
66+
const normalizedResource = raw.toLowerCase();
67+
if (/[^a-z0-9-]/.test(normalizedResource)) {
68+
throw new Error(
69+
`Azure Claude resource name "${raw}" contains invalid characters. ` +
70+
"Only lowercase letters, numbers, and hyphens are allowed (e.g. fabric-hub).",
71+
);
72+
}
73+
if (!normalizedResource) {
74+
throw new Error("Azure Claude resource name must contain alphanumeric characters or hyphens.");
75+
}
76+
return `https://${normalizedResource}${ANTHROPIC_AZURE_HOST_SUFFIX}${ANTHROPIC_AZURE_API_SUFFIX}`;
77+
}
78+
79+
export function resolveAnthropicAzureResourceName(
80+
baseUrl: string | null | undefined,
81+
): string | undefined {
82+
if (!baseUrl) {
83+
return undefined;
84+
}
85+
try {
86+
const url = ensureHttpsUrl(baseUrl);
87+
const host = url.hostname.toLowerCase();
88+
if (!host.endsWith(ANTHROPIC_AZURE_HOST_SUFFIX)) {
89+
return undefined;
90+
}
91+
const resource = host.slice(0, -ANTHROPIC_AZURE_HOST_SUFFIX.length);
92+
return resource || undefined;
93+
} catch {
94+
return undefined;
95+
}
96+
}
97+
98+
export function resolveAnthropicAzureBaseUrlFromEnv(env: NodeJS.ProcessEnv): string | undefined {
99+
const baseUrlCandidates = [env.ANTHROPIC_FOUNDRY_BASE_URL, env.AZURE_CLAUDE_BASE_URL];
100+
for (const candidate of baseUrlCandidates) {
101+
if (candidate && candidate.trim()) {
102+
try {
103+
return normalizeAnthropicAzureBaseUrl(candidate);
104+
} catch {
105+
// fall through to resources if URL invalid
106+
}
107+
}
108+
}
109+
const resourceCandidates = [env.ANTHROPIC_FOUNDRY_RESOURCE, env.AZURE_CLAUDE_RESOURCE];
110+
for (const candidate of resourceCandidates) {
111+
if (candidate && candidate.trim()) {
112+
try {
113+
return normalizeAnthropicAzureBaseUrl(candidate);
114+
} catch {
115+
// ignore invalid resources, try next
116+
}
117+
}
118+
}
119+
return undefined;
120+
}

src/commands/auth-choice-options.static.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ export type AuthChoiceGroup = {
2020
};
2121

2222
export const CORE_AUTH_CHOICE_OPTIONS: ReadonlyArray<AuthChoiceOption> = [
23+
{
24+
value: "anthropic-azure-api-key",
25+
label: "Azure Claude (AI Foundry)",
26+
hint: "Azure AI Foundry resource + Claude API key",
27+
groupId: "anthropic",
28+
groupLabel: "Anthropic",
29+
groupHint: "setup-token + API key",
30+
},
2331
{
2432
value: "chutes",
2533
label: "Chutes (OAuth)",

src/commands/auth-choice-options.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ describe("buildAuthChoiceOptions", () => {
194194
for (const value of [
195195
"github-copilot",
196196
"token",
197+
"anthropic-azure-api-key",
197198
"zai-api-key",
198199
"xiaomi-api-key",
199200
"minimax-global-api",

0 commit comments

Comments
 (0)