Compare commits

...
32 Commits
Author SHA1 Message Date
Filip ed11c895c6 Merge branch 'dev' into azure-cli-auth 2026-08-17 14:48:01 +02:00
Filip Hejmowski 8bc87d3c6a better listing 2026-08-17 14:33:37 +02:00
Filip Hejmowski 1ec78c705a clean uppp 2026-08-17 13:41:54 +02:00
Filip Hejmowski ede6c4f1a6 remove unnecessary maunal resource / sub id handling 2026-08-17 13:10:51 +02:00
Filip Hejmowski c34171845e refactor 2026-08-17 12:50:57 +02:00
Filip Hejmowski 4a7a497597 proper model filtering for azure 2026-08-17 12:38:41 +02:00
Filip Hejmowski b7f72b1bd4 feat: azure oauth 2026-08-17 12:09:11 +02:00
Aiden Cline cff8ba313a Merge branch 'dev' into feat/azure-oauth 2026-08-17 00:01:12 -05:00
OpeOginni 836fcfb742 Merge branch 'dev' into feat/azure-oauth 2026-08-07 12:13:56 +02:00
OpeOginni 03d1099933 Merge branch 'dev' into feat/azure-oauth 2026-07-30 19:08:05 +02:00
OpeOginni e41e416beb chore: merge dev into azure oauth 2026-07-20 17:30:37 +02:00
OpeOginni 2d16759723 Merge branch 'dev' into feat/azure-oauth 2026-07-10 11:06:33 +02:00
OpeOginni a41ed1cdf2 Merge branch 'dev' into feat/azure-oauth 2026-07-08 08:12:52 +02:00
OpeOginni 24479e2df9 fix: run az cli command/check before storing during azure provider
connection
2026-07-08 08:06:41 +02:00
OpeOginni 365a95c91b chore: improve comment v2 & moved delete of azureOpenAICompatibleBaseURL 2026-06-29 14:03:09 +02:00
OpeOginni 49ca92621d chore: improve comment 2026-06-29 14:02:35 +02:00
OpeOginni 92aa12a609 fix(provider): preserve cognitive model endpoints in Oauth connection 2026-06-29 13:53:15 +02:00
OpeOginni 484b662665 Merge branch 'dev' into feat/azure-oauth 2026-06-26 17:13:33 +02:00
OpeOginni e8d955a40c fix(provider): resolve azure cognitive services oauth endpoints 2026-06-26 17:13:03 +02:00
OpeOginni 7adfe50b9a fix(provider): resolve azure cognitive services resource vars 2026-06-23 00:14:44 +02:00
OpeOginni 5613ebb514 docs: fixed description of azure oauth 2026-06-22 17:24:03 +02:00
OpeOginni c0ecd5da0e fix: validate azure cli token expiry parsing 2026-06-22 17:17:14 +02:00
OpeOginni be5f5dbc29 fix: prefer azure cli expires_on for token cache 2026-06-22 17:13:47 +02:00
OpeOginni 3cf9cfb6f3 docs: display to use the Foundry inference role for Cognitive Services 2026-06-22 17:11:41 +02:00
OpeOginni fcc21f3dc5 fix: Remove the Anthropic API-key header when using bearer
authentication
2026-06-22 17:08:36 +02:00
OpeOginni fa40659b7f fix: Preserve model-specific endpoints for Azure Cognitive Services
OAuth
2026-06-22 17:07:38 +02:00
OpeOginni fdf14a8462 chore: improved azure oauth method name and ran format 2026-06-16 16:34:13 +02:00
OpeOginni db43ee0b05 docs: revert localized provider updates 2026-06-16 16:00:21 +02:00
OpeOginni 2b53cf0f4a Merge branch 'dev' into feat/azure-oauth 2026-06-16 15:55:55 +02:00
OpeOginni dffa1045d3 Merge branch 'dev' into feat/azure-oauth 2026-06-08 14:02:33 +02:00
OpeOginni d862d02d0e docs: added more docs on new methods to connect azure and az cognitive 2026-06-08 13:24:28 +02:00
OpeOginni 4abac2a431 feat(opencode): added oauth to azure through MS Entra ID and az cli 2026-06-08 13:23:59 +02:00
10 changed files with 1433 additions and 57 deletions
-26
View File
@@ -1,26 +0,0 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
const prompts = []
if (!process.env.AZURE_RESOURCE_NAME) {
prompts.push({
type: "text" as const,
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
})
}
return {
auth: {
provider: "azure",
methods: [
{
type: "api",
label: "API key",
prompts,
},
],
},
}
}
@@ -0,0 +1,124 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Auth } from "@opencode-ai/sdk/v2"
import { Predicate } from "effect"
import { azureResourceName, foundryProjectEndpoint } from "./schema"
import {
AZURE_FOUNDRY_SCOPE,
AZURE_DISCOVERY_TIMEOUT,
createAzureAuth,
deployedModels,
listAzureDeployments,
listAzureResourceDeployments,
resolveAzureResourceID,
type AzureAccessToken,
type AzureAuthPluginOptions,
type AzureRequest,
} from "./shared"
const AZURE_COGNITIVE_SERVICES_API_KEY_ENV = "AZURE_COGNITIVE_SERVICES_API_KEY"
const AZURE_FOUNDRY_PROJECT_ENDPOINT_ENV = "AZURE_AI_PROJECT_ENDPOINT"
export async function AzureCognitiveServicesAuthPlugin(_input: PluginInput): Promise<Hooks> {
return createAzureCognitiveServicesAuthHooks()
}
export function createAzureCognitiveServicesAuthHooks(options: AzureAuthPluginOptions = {}): Hooks {
const shared = createAzureAuth(
{
provider: "azure-cognitive-services",
envs: [AZURE_FOUNDRY_PROJECT_ENDPOINT_ENV],
scope: AZURE_FOUNDRY_SCOPE,
key: "projectEndpoint",
message: "Enter Microsoft Foundry Project Endpoint",
placeholder: "https://my-resource.services.ai.azure.com/api/projects/my-project",
validationMessage: "Enter a Project endpoint like https://RESOURCE.services.ai.azure.com/api/projects/PROJECT",
instructions:
"Sign in with `az login`. Assign the signed-in identity the Foundry User role on this Foundry resource; Owner or Contributor alone is not sufficient.",
normalize: foundryProjectEndpoint,
},
options,
)
return {
auth: shared.auth,
provider: {
id: "azure-cognitive-services",
async models(info, context) {
const apiKey = process.env[AZURE_COGNITIVE_SERVICES_API_KEY_ENV]
const auth: Auth | undefined = context.auth ?? (apiKey ? { type: "api", key: apiKey } : undefined)
const endpoint = [
process.env[AZURE_FOUNDRY_PROJECT_ENDPOINT_ENV],
auth?.type === "oauth" ? auth.accountId : undefined,
auth?.type === "api" ? auth.metadata?.projectEndpoint : undefined,
]
.map(foundryProjectEndpoint)
.find(Predicate.isString)
if (!endpoint || !auth) return info.models
const deployments = await listAzureFoundryDeployments(
endpoint,
auth,
shared.credential,
shared.token,
shared.request,
AbortSignal.timeout(AZURE_DISCOVERY_TIMEOUT),
).catch(() => [])
return deployedModels(info.models, deployments)
},
},
}
}
async function listAzureFoundryDeployments(
endpoint: string,
auth: Auth,
credential: (scope: string, signal?: AbortSignal) => Promise<AzureAccessToken>,
token: (scope: string, signal?: AbortSignal) => Promise<string>,
request: AzureRequest,
signal: AbortSignal,
) {
const project = listAzureFoundryProjectDeployments(endpoint, auth, token, request, signal).catch(() => [])
if (auth.type !== "oauth") return project
const resource = listAzureFoundryResourceDeployments(endpoint, credential, token, request, signal).catch(() => [])
return [...(await project), ...(await resource)]
}
async function listAzureFoundryProjectDeployments(
endpoint: string,
auth: Auth,
token: (scope: string, signal?: AbortSignal) => Promise<string>,
request: AzureRequest,
signal: AbortSignal,
) {
if (auth.type === "api") {
return listAzureDeployments(
`${endpoint}/deployments?api-version=v1&deploymentType=ModelDeployment`,
new Headers({ "api-key": auth.key }),
request,
(deployment) => deployment.type === "ModelDeployment",
signal,
)
}
if (auth.type !== "oauth") return []
return listAzureDeployments(
`${endpoint}/deployments?api-version=v1&deploymentType=ModelDeployment`,
new Headers({ authorization: `Bearer ${await token(AZURE_FOUNDRY_SCOPE, signal)}` }),
request,
(deployment) => deployment.type === "ModelDeployment",
signal,
)
}
async function listAzureFoundryResourceDeployments(
endpoint: string,
credential: (scope: string, signal?: AbortSignal) => Promise<AzureAccessToken>,
token: (scope: string, signal?: AbortSignal) => Promise<string>,
request: AzureRequest,
signal: AbortSignal,
) {
const resourceName = azureResourceName(endpoint)
if (!resourceName) return []
const resourceID = await resolveAzureResourceID(resourceName, credential, request, signal)
return listAzureResourceDeployments(resourceID, token, request, signal)
}
@@ -0,0 +1,66 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { azureAccount } from "./schema"
import {
AZURE_COGNITIVE_SERVICES_SCOPE,
AZURE_DISCOVERY_TIMEOUT,
createAzureAuth,
deployedModels,
listAzureResourceDeployments,
resolveAzureResourceID,
type AzureAuthPluginOptions,
} from "./shared"
const AZURE_RESOURCE_ID_ENV = "AZURE_RESOURCE_ID"
export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
return createAzureAuthHooks()
}
export function createAzureAuthHooks(options: AzureAuthPluginOptions = {}): Hooks {
const shared = createAzureAuth(
{
provider: "azure",
envs: [AZURE_RESOURCE_ID_ENV, "AZURE_RESOURCE_NAME"],
scope: AZURE_COGNITIVE_SERVICES_SCOPE,
key: "resourceName",
message: "Enter Azure Resource Name or Resource ID",
placeholder: "my-models",
validationMessage: "Enter an Azure Resource Name like my-models or a full Resource ID",
instructions:
"Sign in with `az login`. Assign the signed-in identity the Cognitive Services OpenAI User role on this resource; Owner or Contributor alone is not sufficient.",
normalize(input) {
const account = azureAccount(input)
return account?.resourceID ?? account?.resourceName
},
},
options,
)
return {
auth: shared.auth,
provider: {
id: "azure",
async models(info, context) {
if (context.auth?.type !== "oauth") return info.models
const account =
azureAccount(process.env[AZURE_RESOURCE_ID_ENV]) ??
azureAccount(context.auth.accountId) ??
azureAccount(process.env.AZURE_RESOURCE_NAME)
if (!account) return info.models
const signal = AbortSignal.timeout(AZURE_DISCOVERY_TIMEOUT)
const resourceID =
account.resourceID ??
(await resolveAzureResourceID(account.resourceName, shared.credential, shared.request, signal).catch(
() => undefined,
))
if (!resourceID) return {}
const deployments = await listAzureResourceDeployments(resourceID, shared.token, shared.request, signal).catch(
() => [],
)
return deployedModels(info.models, deployments)
},
},
}
}
@@ -0,0 +1,51 @@
import { Option, Predicate, Schema } from "effect"
export const AzureResourceID = Schema.NonEmptyString.check(
Schema.isPattern(
/^\/subscriptions\/[^/]+\/resourceGroups\/[^/]+\/providers\/Microsoft\.CognitiveServices\/accounts\/[^/]+\/?$/i,
),
)
const AzureResourceName = Schema.NonEmptyString.check(Schema.isPattern(/^[a-z0-9][a-z0-9-]*$/i))
const decodeAzureResourceID = Schema.decodeUnknownOption(AzureResourceID)
const decodeAzureResourceName = Schema.decodeUnknownOption(AzureResourceName)
const decodeURL = Schema.decodeUnknownOption(Schema.URLFromString)
export function azureAccount(input: unknown) {
if (!Predicate.isString(input)) return undefined
const value = input.trim()
const resourceID = decodeAzureResourceID(value)
if (Option.isSome(resourceID)) {
const normalized = resourceID.value.replace(/\/$/, "")
const resourceName = normalized.split("/").at(-1)
if (resourceName) return { resourceID: normalized, resourceName }
}
const resourceName = decodeAzureResourceName(value)
if (Option.isSome(resourceName)) return { resourceName: resourceName.value }
return undefined
}
export function azureResourceName(input: unknown) {
const account = azureAccount(input)
if (account) return account.resourceName
const endpoint = decodeURL(input)
if (Option.isNone(endpoint)) return undefined
if (endpoint.value.protocol !== "https:") return undefined
const suffix = [".services.ai.azure.com", ".cognitiveservices.azure.com", ".openai.azure.com"].find((value) =>
endpoint.value.hostname.endsWith(value),
)
if (!suffix) return undefined
return Option.getOrUndefined(decodeAzureResourceName(endpoint.value.hostname.slice(0, -suffix.length)))
}
export function foundryProjectEndpoint(input: unknown) {
const endpoint = decodeURL(input)
if (Option.isNone(endpoint)) return undefined
if (endpoint.value.protocol !== "https:") return undefined
if (!endpoint.value.hostname.endsWith(".services.ai.azure.com")) return undefined
if (!/^\/api\/projects\/[^/]+\/?$/.test(endpoint.value.pathname)) return undefined
return `${endpoint.value.origin}${endpoint.value.pathname.replace(/\/$/, "")}`
}
@@ -0,0 +1,375 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { Hooks } from "@opencode-ai/plugin"
import type { Provider } from "@opencode-ai/sdk/v2"
import { Option, Predicate, Schema } from "effect"
import { OAUTH_DUMMY_KEY } from "../../auth"
import { AzureResourceID } from "./schema"
export const AZURE_COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default"
export const AZURE_FOUNDRY_SCOPE = "https://ai.azure.com/.default"
export const AZURE_DISCOVERY_TIMEOUT = 5_000
export const AZURE_RESOURCE_MANAGER_SCOPE = "https://management.azure.com/.default"
const AZURE_TOKEN_REFRESH_BUFFER = 60_000
class AzureCliToken extends Schema.Class<AzureCliToken>("AzureCliToken")({
accessToken: Schema.NonEmptyString,
expires_on: Schema.optionalKey(Schema.Number),
expiresOn: Schema.optionalKey(Schema.String),
subscription: Schema.optionalKey(Schema.NullOr(Schema.String.check(Schema.isUUID()))),
}) {}
const decodeAzureCliToken = Schema.decodeUnknownOption(Schema.fromJsonString(AzureCliToken))
export class AzureDeployment extends Schema.Class<AzureDeployment>("AzureDeployment")({
name: Schema.NonEmptyString,
type: Schema.optionalKey(Schema.String),
modelName: Schema.optionalKey(Schema.NonEmptyString),
properties: Schema.optionalKey(
Schema.Struct({
provisioningState: Schema.optionalKey(Schema.String),
model: Schema.optionalKey(
Schema.Struct({
name: Schema.NonEmptyString,
format: Schema.optionalKey(Schema.String),
}),
),
}),
),
}) {}
const AzureDeploymentPage = Schema.Struct({
value: Schema.Array(AzureDeployment),
nextLink: Schema.optionalKey(Schema.String),
})
const decodeAzureDeploymentPage = Schema.decodeUnknownOption(Schema.fromJsonString(AzureDeploymentPage))
class AzureResource extends Schema.Class<AzureResource>("AzureResource")({
id: AzureResourceID,
name: Schema.NonEmptyString,
}) {}
const AzureResourcePage = Schema.Struct({
value: Schema.Array(AzureResource),
nextLink: Schema.optionalKey(Schema.String),
})
const decodeAzureResourcePage = Schema.decodeUnknownOption(Schema.fromJsonString(AzureResourcePage))
type AzureCliCommandResult = {
stdout: string
stderr: string
exitCode: number
}
type AzureCliCommand = (scope: string, signal?: AbortSignal) => Promise<AzureCliCommandResult>
type AzureAccountConfig = {
provider: "azure" | "azure-cognitive-services"
envs: ReadonlyArray<string>
scope: string
key: string
message: string
placeholder: string
validationMessage: string
instructions: string
normalize: (input: unknown) => string | undefined
}
export type AzureRequest = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
export type AzureAccessToken = {
token: string
subscription?: string
}
export type AzureAuthPluginOptions = {
tokenCommand?: AzureCliCommand
request?: AzureRequest
}
type AzureAuthState = {
auth: NonNullable<Hooks["auth"]>
credential: (scope: string, signal?: AbortSignal) => Promise<AzureAccessToken>
token: (scope: string, signal?: AbortSignal) => Promise<string>
request: AzureRequest
}
export function createAzureAuth(config: AzureAccountConfig, options: AzureAuthPluginOptions = {}): AzureAuthState {
const credential = azureCliTokenProvider(options.tokenCommand ?? runAzureCliTokenCommand)
const token = async (scope: string, signal?: AbortSignal) => (await credential(scope, signal)).token
const request = options.request ?? fetch
const configuredAccount = accountFromEnvironment(config)
const prompts: NonNullable<Hooks["auth"]>["methods"][number]["prompts"] = configuredAccount
? []
: [
{
type: "text",
key: config.key,
message: config.message,
placeholder: config.placeholder,
validate: (value: string) => (config.normalize(value) ? undefined : config.validationMessage),
},
]
return {
credential,
token,
request,
auth: {
provider: config.provider,
async loader(getAuth) {
const auth = await getAuth()
if (auth.type !== "oauth") return {}
return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
const currentAuth = await getAuth()
if (currentAuth.type !== "oauth") return request(requestInput, init)
const scope = scopeForRequest(requestInput)
if (!scope) throw new Error("Azure OAuth only supports Azure HTTPS endpoints")
const headers = new Headers(requestInput instanceof Request ? requestInput.headers : undefined)
new Headers(init?.headers).forEach((value, key) => headers.set(key, value))
headers.delete("api-key")
headers.delete("x-api-key")
headers.set("authorization", `Bearer ${await token(scope)}`)
headers.set("User-Agent", `opencode/${InstallationVersion}`)
return request(requestInput, { ...init, headers })
},
}
},
methods: [
{
type: "api",
label: "API key",
prompts,
},
{
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
prompts,
authorize: async (inputs) => ({
// Azure CLI owns the interactive sign-in, so OpenCode has no authorization URL to open.
url: "",
instructions: config.instructions,
method: "auto",
callback: async () => {
const account = inputs?.[config.key]
const normalized = account ? config.normalize(account) : accountFromEnvironment(config)
if (!normalized) throw new Error(account ? config.validationMessage : config.message)
await token(config.scope)
return {
type: "success",
access: OAUTH_DUMMY_KEY,
refresh: OAUTH_DUMMY_KEY,
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
accountId: normalized,
}
},
}),
},
],
},
}
}
export async function listAzureDeployments(
url: string,
headers: HeadersInit,
request: AzureRequest,
include: (deployment: AzureDeployment) => boolean,
signal: AbortSignal,
deployments: ReadonlyArray<AzureDeployment> = [],
): Promise<ReadonlyArray<AzureDeployment>> {
const response = await request(url, { headers, signal })
if (!response.ok) throw new Error(`Failed to list Azure deployments (${response.status})`)
const decoded = decodeAzureDeploymentPage(await response.text())
if (Option.isNone(decoded)) throw new Error("Azure returned an invalid deployments response")
const found = [...deployments, ...decoded.value.value.filter(include)]
if (!decoded.value.nextLink) return found
const next = new URL(decoded.value.nextLink, url)
if (next.origin !== new URL(url).origin) throw new Error("Azure returned an invalid deployments page")
return listAzureDeployments(next.toString(), headers, request, include, signal, found)
}
export function deployedModels(models: Provider["models"], deployments: ReadonlyArray<AzureDeployment>) {
const found = new Map<string, Provider["models"][string]>()
deployments.forEach((deployment) => {
const modelID = deployedModelID(models, deployment)
if (!modelID) return
const model = models[modelID]
if (!model) return
found.set(modelID, {
...model,
api: {
...model.api,
id: deployment.name,
},
})
})
return Object.fromEntries(found)
}
export async function resolveAzureResourceID(
resourceName: string,
credential: (scope: string, signal?: AbortSignal) => Promise<AzureAccessToken>,
request: AzureRequest,
signal: AbortSignal,
) {
const access = await credential(AZURE_RESOURCE_MANAGER_SCOPE, signal)
if (!access.subscription) {
throw new Error(
"Azure CLI did not return an active subscription. Run `az account set --subscription NAME_OR_ID` and try again.",
)
}
return findAzureResourceID(
`https://management.azure.com/subscriptions/${access.subscription}/providers/Microsoft.CognitiveServices/accounts?api-version=2024-10-01`,
resourceName,
access.token,
request,
signal,
)
}
export async function listAzureResourceDeployments(
resourceID: string,
token: (scope: string, signal?: AbortSignal) => Promise<string>,
request: AzureRequest,
signal: AbortSignal,
) {
return listAzureDeployments(
`https://management.azure.com${resourceID}/deployments?api-version=2024-10-01`,
new Headers({ authorization: `Bearer ${await token(AZURE_RESOURCE_MANAGER_SCOPE, signal)}` }),
request,
(deployment) => deployment.properties?.provisioningState === "Succeeded",
signal,
)
}
function accountFromEnvironment(config: AzureAccountConfig) {
return config.envs.map((name) => config.normalize(process.env[name])).find(Predicate.isString)
}
function scopeForRequest(input: RequestInfo | URL) {
const url = input instanceof Request ? new URL(input.url) : input instanceof URL ? input : new URL(input)
if (url.protocol !== "https:") return undefined
if (url.hostname.endsWith(".services.ai.azure.com")) {
if (url.pathname === "/models" || url.pathname.startsWith("/models/")) {
return AZURE_COGNITIVE_SERVICES_SCOPE
}
return AZURE_FOUNDRY_SCOPE
}
if (url.hostname.endsWith(".cognitiveservices.azure.com")) return AZURE_COGNITIVE_SERVICES_SCOPE
if (url.hostname.endsWith(".openai.azure.com")) return AZURE_COGNITIVE_SERVICES_SCOPE
return undefined
}
async function findAzureResourceID(
url: string,
resourceName: string,
token: string,
request: AzureRequest,
signal: AbortSignal,
) {
const response = await request(url, { headers: { authorization: `Bearer ${token}` }, signal })
if (!response.ok) {
throw new Error(`Failed to list Azure resources in the active subscription (${response.status})`)
}
const decoded = decodeAzureResourcePage(await response.text())
if (Option.isNone(decoded)) throw new Error("Azure returned an invalid resources response")
const resource = decoded.value.value.find((item) => item.name.toLowerCase() === resourceName.toLowerCase())
if (resource) return resource.id.replace(/\/$/, "")
if (decoded.value.nextLink) {
const next = new URL(decoded.value.nextLink, url)
if (next.origin !== new URL(url).origin) throw new Error("Azure returned an invalid resources page")
return findAzureResourceID(next.toString(), resourceName, token, request, signal)
}
throw new Error(
`Azure resource "${resourceName}" was not found in the active subscription. Run \`az account set --subscription NAME_OR_ID\` or reconnect using the full Resource ID.`,
)
}
function deployedModelID(models: Provider["models"], deployment: AzureDeployment) {
if (models[deployment.name]) return deployment.name
const modelName = deployment.modelName ?? deployment.properties?.model?.name
if (!modelName) return undefined
return Object.keys(models).find((modelID) => modelID.toLowerCase() === modelName.toLowerCase())
}
function azureCliTokenProvider(command: NonNullable<AzureAuthPluginOptions["tokenCommand"]>) {
type CachedToken = AzureAccessToken & { expires: number }
const cached = new Map<string, CachedToken>()
const pending = new Map<string, Promise<CachedToken>>()
return async (scope: string, signal?: AbortSignal) => {
const hit = cached.get(scope)
if (hit && hit.expires - Date.now() > AZURE_TOKEN_REFRESH_BUFFER) return hit
const existing = pending.get(scope)
if (existing) return existing
const loading = loadAzureCliToken(command, scope, signal)
.then((credential) => {
cached.set(scope, credential)
return credential
})
.finally(() => pending.delete(scope))
pending.set(scope, loading)
return loading
}
}
async function loadAzureCliToken(
command: NonNullable<AzureAuthPluginOptions["tokenCommand"]>,
scope: string,
signal?: AbortSignal,
) {
const result = await command(scope, signal)
if (result.exitCode !== 0) {
throw new Error(result.stderr.trim() || "Failed to get Azure access token. Run `az login` and try again.")
}
const decoded = decodeAzureCliToken(result.stdout)
if (Option.isNone(decoded)) throw new Error("Azure CLI did not return a valid access token")
const expires =
decoded.value.expires_on !== undefined
? decoded.value.expires_on * 1000
: decoded.value.expiresOn
? new Date(decoded.value.expiresOn).getTime()
: Number.NaN
if (!Number.isFinite(expires)) throw new Error("Azure CLI did not return a valid token expiry")
return {
token: decoded.value.accessToken,
expires,
...(decoded.value.subscription ? { subscription: decoded.value.subscription } : {}),
}
}
async function runAzureCliTokenCommand(scope: string, signal?: AbortSignal) {
try {
const proc = Bun.spawn(["az", "account", "get-access-token", "--scope", scope, "--output", "json"], {
stdout: "pipe",
stderr: "pipe",
signal,
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
return { stdout, stderr, exitCode }
} catch (error) {
throw new Error("Azure CLI could not be run. Install `az`, run `az login`, and try again.", {
cause: error,
})
}
}
+2
View File
@@ -18,6 +18,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure"
import { AzureCognitiveServicesAuthPlugin } from "./azure/cognitive-services"
import { DigitalOceanAuthPlugin } from "./digitalocean"
import { XaiAuthPlugin } from "./xai"
import { SnowflakeCortexAuthPlugin } from "./snowflake-cortex"
@@ -77,6 +78,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
AzureCognitiveServicesAuthPlugin,
DigitalOceanAuthPlugin,
SnowflakeCortexAuthPlugin,
XaiAuthPlugin,
+50 -7
View File
@@ -8,6 +8,7 @@ import { NoSuchModelError, type Provider as SDK } from "ai"
import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin"
import { azureResourceName } from "../plugin/azure/schema"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import { ModelsDev } from "@opencode-ai/core/models-dev"
@@ -18,7 +19,7 @@ import { iife } from "@/util/iife"
import { Global } from "@opencode-ai/core/global"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { Effect, Layer, Context, Predicate, Schema, Types } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { EffectPromise } from "@/effect/promise"
@@ -242,10 +243,16 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
const auth = yield* dep.auth(provider.id)
const resource = iife(() => {
return [
provider.options?.resourceID,
provider.options?.resourceName,
auth?.type === "api" ? auth.metadata?.resourceID : undefined,
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
auth?.type === "oauth" ? auth.accountId : undefined,
env["AZURE_RESOURCE_ID"],
env["AZURE_RESOURCE_NAME"],
].find((name) => typeof name === "string" && name.trim() !== "")
]
.map(azureResourceName)
.find(Predicate.isString)
})
if (!resource && !provider.options?.baseURL) {
@@ -253,7 +260,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
async getModel() {
throw new Error(
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
"AZURE_RESOURCE_ID or AZURE_RESOURCE_NAME is missing; set one in the environment or reconnect the Azure provider",
)
},
}
@@ -278,17 +285,39 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
}
}),
"azure-cognitive-services": Effect.fnUntraced(function* (provider: Info) {
const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
const env = yield* dep.env()
const auth = yield* dep.auth(provider.id)
const resourceName = [
provider.options?.resourceName,
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
auth?.type === "api" ? auth.metadata?.projectEndpoint : undefined,
auth?.type === "oauth" ? auth.accountId : undefined,
env["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME"],
env["AZURE_AI_PROJECT_ENDPOINT"],
]
.map(azureResourceName)
.find(Predicate.isString)
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
},
options: {
baseURL: resourceName
// Used only when a Cognitive model relies on the default @ai-sdk/azure endpoint.
azureOpenAICompatibleBaseURL: resourceName
? `https://${resourceName}.cognitiveservices.azure.com/openai${provider.options?.useDeploymentBasedUrls ? "" : "/v1"}`
: undefined,
},
vars(_options): Record<string, string> {
if (resourceName) {
return {
// Some Cognitive Services catalog entries use the generic Azure placeholder.
AZURE_RESOURCE_NAME: resourceName,
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: resourceName,
}
}
return {}
},
}
}),
"amazon-bedrock": Effect.fnUntraced(function* () {
@@ -1722,14 +1751,27 @@ const layer = Layer.effect(
delete options.fetch
}
if (
model.providerID === "azure-cognitive-services" &&
model.api.npm === "@ai-sdk/azure" &&
!model.api.url &&
(!Predicate.isString(options["baseURL"]) || options["baseURL"] === "") &&
Predicate.isString(options["azureOpenAICompatibleBaseURL"]) &&
options["azureOpenAICompatibleBaseURL"] !== ""
) {
// Azure Cognitive Services hosts multiple protocol shapes under one provider.
// Only default @ai-sdk/azure models use this Azure OpenAI-compatible URL.
options["baseURL"] = options["azureOpenAICompatibleBaseURL"]
}
if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
options["includeUsage"] = true
}
const baseURL = iife(() => {
let url =
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
if (!url) return
Predicate.isString(options["baseURL"]) && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
if (!url) return undefined
const loader = s.varsLoaders[model.providerID]
if (loader) {
@@ -1748,6 +1790,7 @@ const layer = Layer.effect(
})
if (baseURL !== undefined) options["baseURL"] = baseURL
delete options["azureOpenAICompatibleBaseURL"]
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
if (model.headers)
options["headers"] = {
+622
View File
@@ -0,0 +1,622 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { Hooks } from "@opencode-ai/plugin"
import type { Auth, Provider } from "@opencode-ai/sdk/v2"
import { Predicate } from "effect"
import { OAUTH_DUMMY_KEY } from "../../src/auth"
import { createAzureAuthHooks } from "../../src/plugin/azure"
import { createAzureCognitiveServicesAuthHooks } from "../../src/plugin/azure/cognitive-services"
const provider: Provider = {
id: "azure-cognitive-services",
name: "Azure Cognitive Services",
source: "custom",
env: [],
options: {},
models: {},
}
const oauth: Auth = {
type: "oauth",
access: OAUTH_DUMMY_KEY,
refresh: OAUTH_DUMMY_KEY,
expires: Date.now() + 60 * 60 * 1000,
accountId: "https://test-resource.services.ai.azure.com/api/projects/test-project",
}
const subscriptionID = "00000000-1111-4222-8333-444444444444"
const projectEndpoint = process.env.AZURE_AI_PROJECT_ENDPOINT
const cognitiveApiKey = process.env.AZURE_COGNITIVE_SERVICES_API_KEY
const resourceID = process.env.AZURE_RESOURCE_ID
const resourceName = process.env.AZURE_RESOURCE_NAME
beforeEach(() => {
delete process.env.AZURE_AI_PROJECT_ENDPOINT
delete process.env.AZURE_COGNITIVE_SERVICES_API_KEY
delete process.env.AZURE_RESOURCE_ID
delete process.env.AZURE_RESOURCE_NAME
})
afterEach(() => {
if (projectEndpoint === undefined) delete process.env.AZURE_AI_PROJECT_ENDPOINT
else process.env.AZURE_AI_PROJECT_ENDPOINT = projectEndpoint
if (cognitiveApiKey === undefined) delete process.env.AZURE_COGNITIVE_SERVICES_API_KEY
else process.env.AZURE_COGNITIVE_SERVICES_API_KEY = cognitiveApiKey
if (resourceID === undefined) delete process.env.AZURE_RESOURCE_ID
else process.env.AZURE_RESOURCE_ID = resourceID
if (resourceName === undefined) delete process.env.AZURE_RESOURCE_NAME
else process.env.AZURE_RESOURCE_NAME = resourceName
})
function loader(hooks: Hooks) {
if (!hooks.auth?.loader) throw new Error("Azure auth loader is missing")
return hooks.auth.loader
}
function customFetch(options: Record<string, unknown>) {
const result = options["fetch"]
if (!Predicate.isFunction(result)) throw new Error("Azure custom fetch is missing")
return async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await result(input, init)
if (!(response instanceof Response)) throw new Error("Azure custom fetch did not return a response")
return response
}
}
function tokenOutput(
accessToken: string,
expires = Date.now() + 60 * 60 * 1000,
subscription: string | null = subscriptionID,
) {
return JSON.stringify({ accessToken, expires_on: Math.floor(expires / 1000), subscription })
}
function models(...ids: string[]): Provider["models"] {
return Object.fromEntries(
ids.map((id) => [
id,
{
id,
providerID: provider.id,
api: { id, url: "", npm: "@ai-sdk/openai-compatible" },
name: id,
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 0, output: 0 },
status: "active" as const,
options: {},
headers: {},
release_date: "",
},
]),
)
}
function captureRequests() {
const requests: Array<{ url: string; headers: Headers }> = []
return {
requests,
request: async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({
url: input instanceof Request ? input.url : input.toString(),
headers: new Headers(init?.headers),
})
return new Response(null, { status: 200 })
},
}
}
describe("plugin.azure", () => {
test("lists only models deployed in the Foundry project", async () => {
process.env.AZURE_AI_PROJECT_ENDPOINT = "not-a-project-endpoint"
const scopes: string[] = []
const requests: Array<{ url: string; headers: Headers }> = []
const hooks = createAzureCognitiveServicesAuthHooks({
request: async (input, init) => {
const url = input instanceof Request ? input.url : input.toString()
requests.push({
url,
headers: new Headers(init?.headers),
})
if (url.startsWith("https://management.azure.com/")) return new Response(null, { status: 403 })
return Response.json({
value: [
{ name: "phi-4-mini", type: "ModelDeployment" },
{ name: "gpt-4.1-mini", type: "ModelDeployment" },
],
})
},
tokenCommand: async (scope) => {
scopes.push(scope)
return { stdout: tokenOutput("foundry-token"), stderr: "", exitCode: 0 }
},
})
const list = hooks.provider?.models
if (!list) throw new Error("Azure provider model hook is missing")
const result = await list(
{ ...provider, models: models("phi-4-mini", "gpt-4.1-mini", "claude-haiku-4-5") },
{ auth: oauth },
)
expect(Object.keys(result)).toEqual(["phi-4-mini", "gpt-4.1-mini"])
expect(new Set(scopes)).toEqual(new Set(["https://ai.azure.com/.default", "https://management.azure.com/.default"]))
expect(requests).toHaveLength(2)
const projectRequest = requests.find((request) => request.url.includes("/api/projects/"))
if (!projectRequest) throw new Error("Foundry project deployments request is missing")
expect(projectRequest.url).toBe(
"https://test-resource.services.ai.azure.com/api/projects/test-project/deployments?api-version=v1&deploymentType=ModelDeployment",
)
expect(projectRequest.headers.get("authorization")).toBe("Bearer foundry-token")
})
test("maps exact Foundry ARM model metadata to OpenCode model IDs", async () => {
const scopes: string[] = []
const requests: string[] = []
const hooks = createAzureCognitiveServicesAuthHooks({
request: async (input) => {
const url = input instanceof Request ? input.url : input.toString()
requests.push(url)
if (url.includes("/providers/Microsoft.CognitiveServices/accounts?")) {
return Response.json({
value: [
{
id: `/subscriptions/${subscriptionID}/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource`,
name: "test-resource",
},
],
})
}
if (url.startsWith("https://management.azure.com/")) {
return Response.json({
value: [
{
name: "production-gpt",
properties: {
provisioningState: "Succeeded",
model: { name: "gpt-5-mini", format: "OpenAI" },
},
},
{
name: "production-claude",
properties: {
provisioningState: "Succeeded",
model: { name: "claude-haiku-4-5", format: "Anthropic" },
},
},
{
name: "custom-instruct",
properties: {
provisioningState: "Succeeded",
model: { name: "custom-instruct" },
},
},
],
})
}
return Response.json({
value: [{ name: "gpt-5-mini", type: "ModelDeployment", modelName: "gpt-5-mini" }],
})
},
tokenCommand: async (scope) => {
scopes.push(scope)
return { stdout: tokenOutput(`${scope}-token`), stderr: "", exitCode: 0 }
},
})
const list = hooks.provider?.models
if (!list) throw new Error("Azure provider model hook is missing")
const result = await list(
{ ...provider, models: models("gpt-5-mini", "claude-haiku-4-5", "custom") },
{ auth: oauth },
)
expect(Object.keys(result)).toEqual(["gpt-5-mini", "claude-haiku-4-5"])
expect(result["gpt-5-mini"].api.id).toBe("production-gpt")
expect(result["claude-haiku-4-5"].api.id).toBe("production-claude")
expect(new Set(scopes)).toEqual(new Set(["https://ai.azure.com/.default", "https://management.azure.com/.default"]))
expect(requests).toHaveLength(3)
})
test("lists project deployments with an environment API key without invoking Azure CLI", async () => {
process.env.AZURE_AI_PROJECT_ENDPOINT = "https://test-resource.services.ai.azure.com/api/projects/test-project"
process.env.AZURE_COGNITIVE_SERVICES_API_KEY = "project-key"
let cliCalls = 0
const requests: Array<{ url: string; headers: Headers }> = []
const hooks = createAzureCognitiveServicesAuthHooks({
request: async (input, init) => {
requests.push({
url: input instanceof Request ? input.url : input.toString(),
headers: new Headers(init?.headers),
})
return Response.json({
value: [{ name: "claude-haiku-4-5", type: "ModelDeployment" }],
})
},
tokenCommand: async () => {
cliCalls++
throw new Error("Azure CLI should not be used for API key auth")
},
})
const list = hooks.provider?.models
if (!list) throw new Error("Azure provider model hook is missing")
const result = await list({ ...provider, models: models("gpt-5-mini", "claude-haiku-4-5") }, {})
expect(Object.keys(result)).toEqual(["claude-haiku-4-5"])
expect(cliCalls).toBe(0)
expect(requests).toHaveLength(1)
expect(requests[0].headers.get("api-key")).toBe("project-key")
expect(requests[0].headers.get("authorization")).toBeNull()
})
test("lists succeeded Azure deployments without changing their deployment IDs", async () => {
const scopes: string[] = []
const requests: Array<{ url: string; headers: Headers }> = []
const hooks = createAzureAuthHooks({
request: async (input, init) => {
requests.push({
url: input instanceof Request ? input.url : input.toString(),
headers: new Headers(init?.headers),
})
return Response.json({
value: [
{ name: "gpt-5-mini", properties: { provisioningState: "Succeeded" } },
{ name: "gpt-5.6-luna", properties: { provisioningState: "Creating" } },
{
name: "DeepSeek-V4-Flash",
properties: {
provisioningState: "Succeeded",
model: { name: "DeepSeek-V4-Flash", format: "DeepSeek" },
},
},
],
})
},
tokenCommand: async (scope) => {
scopes.push(scope)
return { stdout: tokenOutput("management-token"), stderr: "", exitCode: 0 }
},
})
const list = hooks.provider?.models
if (!list) throw new Error("Azure provider model hook is missing")
const result = await list(
{ ...provider, models: models("gpt-5-mini", "gpt-5.6-luna", "gpt-5-nano", "deepseek-v4-flash") },
{
auth: {
...oauth,
accountId:
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource",
},
},
)
expect(Object.keys(result)).toEqual(["gpt-5-mini", "deepseek-v4-flash"])
expect(result["deepseek-v4-flash"].api.id).toBe("DeepSeek-V4-Flash")
expect(scopes).toEqual(["https://management.azure.com/.default"])
expect(requests).toHaveLength(1)
expect(requests[0].url).toBe(
"https://management.azure.com/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource/deployments?api-version=2024-10-01",
)
expect(requests[0].headers.get("authorization")).toBe("Bearer management-token")
})
test("resolves a Resource Name in the active subscription before listing deployments", async () => {
const scopes: string[] = []
const requests: Array<{ url: string; headers: Headers }> = []
const hooks = createAzureAuthHooks({
request: async (input, init) => {
const url = input instanceof Request ? input.url : input.toString()
requests.push({ url, headers: new Headers(init?.headers) })
if (url.endsWith("/providers/Microsoft.CognitiveServices/accounts?api-version=2024-10-01")) {
return Response.json({
value: [
{
id: `/subscriptions/${subscriptionID}/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource`,
name: "test-resource",
},
],
})
}
return Response.json({
value: [
{ name: "gpt-5-mini", properties: { provisioningState: "Succeeded" } },
{ name: "gpt-5.6-luna", properties: { provisioningState: "Creating" } },
],
})
},
tokenCommand: async (scope) => {
scopes.push(scope)
return { stdout: tokenOutput("management-token"), stderr: "", exitCode: 0 }
},
})
const list = hooks.provider?.models
if (!list) throw new Error("Azure provider model hook is missing")
const result = await list(
{ ...provider, models: models("gpt-5-mini", "gpt-5.6-luna") },
{ auth: { ...oauth, accountId: "test-resource" } },
)
expect(Object.keys(result)).toEqual(["gpt-5-mini"])
expect(scopes).toEqual(["https://management.azure.com/.default"])
expect(requests.map((request) => request.url)).toEqual([
`https://management.azure.com/subscriptions/${subscriptionID}/providers/Microsoft.CognitiveServices/accounts?api-version=2024-10-01`,
`https://management.azure.com/subscriptions/${subscriptionID}/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource/deployments?api-version=2024-10-01`,
])
expect(requests.map((request) => request.headers.get("authorization"))).toEqual([
"Bearer management-token",
"Bearer management-token",
])
})
test("keeps the app usable when Azure CLI does not return an active subscription", async () => {
const hooks = createAzureAuthHooks({
tokenCommand: async () => ({
stdout: tokenOutput("management-token", Date.now() + 60 * 60 * 1000, null),
stderr: "",
exitCode: 0,
}),
})
const list = hooks.provider?.models
if (!list) throw new Error("Azure provider model hook is missing")
expect(
await list({ ...provider, models: models("gpt-5-mini") }, { auth: { ...oauth, accountId: "test-resource" } }),
).toEqual({})
})
test("keeps the app usable when the Resource Name is absent from the active subscription", async () => {
const hooks = createAzureAuthHooks({
request: async () => Response.json({ value: [] }),
tokenCommand: async () => ({ stdout: tokenOutput("management-token"), stderr: "", exitCode: 0 }),
})
const list = hooks.provider?.models
if (!list) throw new Error("Azure provider model hook is missing")
expect(
await list({ ...provider, models: models("gpt-5-mini") }, { auth: { ...oauth, accountId: "missing-resource" } }),
).toEqual({})
})
test("keeps the app usable when the Resource ID cannot list deployments", async () => {
let signal: AbortSignal | null | undefined
const hooks = createAzureAuthHooks({
request: async (_input, init) => {
signal = init?.signal
return new Response(null, { status: 404 })
},
tokenCommand: async () => ({ stdout: tokenOutput("management-token"), stderr: "", exitCode: 0 }),
})
const list = hooks.provider?.models
if (!list) throw new Error("Azure provider model hook is missing")
expect(
await list(
{ ...provider, models: models("gpt-5-mini") },
{
auth: {
...oauth,
accountId:
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/missing-resource",
},
},
),
).toEqual({})
expect(signal).toBeInstanceOf(AbortSignal)
})
test("selects the token scope from the request route and strips API key headers", async () => {
const scopes: string[] = []
const captured = captureRequests()
const hooks = createAzureCognitiveServicesAuthHooks({
request: captured.request,
tokenCommand: async (scope) => {
scopes.push(scope)
return {
stdout: tokenOutput(scope === "https://ai.azure.com/.default" ? "foundry-token" : "cognitive-token"),
stderr: "",
exitCode: 0,
}
},
})
const fetch = customFetch(await loader(hooks)(async () => oauth, provider))
await fetch("https://test-resource.services.ai.azure.com/anthropic/v1/messages", {
headers: { "api-key": "dummy", "x-api-key": "dummy", "x-keep": "yes" },
})
await fetch("https://test-resource.services.ai.azure.com/models/chat/completions")
await fetch("https://test-resource.cognitiveservices.azure.com/openai/v1/responses")
expect(scopes).toEqual(["https://ai.azure.com/.default", "https://cognitiveservices.azure.com/.default"])
expect(captured.requests.map((request) => request.headers.get("authorization"))).toEqual([
"Bearer foundry-token",
"Bearer cognitive-token",
"Bearer cognitive-token",
])
expect(captured.requests[0].headers.get("api-key")).toBeNull()
expect(captured.requests[0].headers.get("x-api-key")).toBeNull()
expect(captured.requests[0].headers.get("x-keep")).toBe("yes")
expect(captured.requests[0].headers.get("user-agent")).toMatch(/^opencode\//)
})
test("does not send Azure OAuth tokens to unsupported endpoints", async () => {
const scopes: string[] = []
const captured = captureRequests()
const hooks = createAzureAuthHooks({
request: captured.request,
tokenCommand: async (scope) => {
scopes.push(scope)
return { stdout: tokenOutput("azure-token"), stderr: "", exitCode: 0 }
},
})
const fetch = customFetch(await loader(hooks)(async () => oauth, provider))
expect(fetch("https://example.com/v1/responses")).rejects.toThrow("Azure OAuth only supports Azure HTTPS endpoints")
expect(scopes).toEqual([])
expect(captured.requests).toEqual([])
})
test("deduplicates concurrent Azure CLI requests and caches the token", async () => {
const scopes: string[] = []
const hooks = createAzureAuthHooks({
request: captureRequests().request,
tokenCommand: async (scope) => {
scopes.push(scope)
await Bun.sleep(20)
return { stdout: tokenOutput("shared-token"), stderr: "", exitCode: 0 }
},
})
const fetch = customFetch(await loader(hooks)(async () => oauth, provider))
const url = "https://test-resource.openai.azure.com/openai/v1/responses"
await Promise.all([fetch(url), fetch(url), fetch(url)])
await fetch(url)
expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default"])
})
test("accepts the legacy expiresOn field", async () => {
const captured = captureRequests()
let calls = 0
const hooks = createAzureAuthHooks({
request: captured.request,
tokenCommand: async () => {
calls++
return {
stdout: JSON.stringify({
accessToken: "legacy-token",
expiresOn: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}),
stderr: "",
exitCode: 0,
}
},
})
const fetch = customFetch(await loader(hooks)(async () => oauth, provider))
await fetch("https://test-resource.openai.azure.com/openai/v1/responses")
await fetch("https://test-resource.openai.azure.com/openai/v1/responses")
expect(calls).toBe(1)
expect(captured.requests[0].headers.get("authorization")).toBe("Bearer legacy-token")
})
test("does not cache invalid Azure CLI output", async () => {
const captured = captureRequests()
let calls = 0
const hooks = createAzureAuthHooks({
request: captured.request,
tokenCommand: async () => {
calls++
return {
stdout: calls === 1 ? "not-json" : tokenOutput("recovered-token"),
stderr: "",
exitCode: 0,
}
},
})
const fetch = customFetch(await loader(hooks)(async () => oauth, provider))
const url = "https://test-resource.openai.azure.com/openai/v1/responses"
const error = await fetch(url).then(
() => undefined,
(error: unknown) => error,
)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw new Error("Expected Azure token loading to fail")
expect(error.message).toBe("Azure CLI did not return a valid access token")
await fetch(url)
expect(calls).toBe(2)
expect(captured.requests[0].headers.get("authorization")).toBe("Bearer recovered-token")
})
test("checks Azure CLI login before storing OAuth metadata", async () => {
const scopes: string[] = []
const hooks = createAzureCognitiveServicesAuthHooks({
tokenCommand: async (scope) => {
scopes.push(scope)
return { stdout: tokenOutput("connect-token"), stderr: "", exitCode: 0 }
},
})
const auth = hooks.auth
const method = auth?.methods.find((method) => method.type === "oauth")
if (!method || method.type !== "oauth") throw new Error("Azure OAuth method is missing")
const prompt = method.prompts?.[0]
if (!prompt || prompt.type !== "text") throw new Error("Azure Project endpoint prompt is missing")
expect(prompt.validate?.("not-a-project-endpoint")).toBe(
"Enter a Project endpoint like https://RESOURCE.services.ai.azure.com/api/projects/PROJECT",
)
expect(prompt.validate?.("https://connected-resource.services.ai.azure.com/api/projects/connected-project")).toBe(
undefined,
)
const authorization = await method.authorize({
projectEndpoint: "https://connected-resource.services.ai.azure.com/api/projects/connected-project/",
})
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
const result = await authorization.callback()
expect(authorization.url).toBe("")
expect(scopes).toEqual(["https://ai.azure.com/.default"])
expect(result).toMatchObject({
type: "success",
access: OAUTH_DUMMY_KEY,
refresh: OAUTH_DUMMY_KEY,
accountId: "https://connected-resource.services.ai.azure.com/api/projects/connected-project",
})
})
test("validates and stores a normalized Azure Resource ID", async () => {
const hooks = createAzureAuthHooks({
tokenCommand: async () => ({ stdout: tokenOutput("connect-token"), stderr: "", exitCode: 0 }),
})
const method = hooks.auth?.methods.find((method) => method.type === "oauth")
if (!method || method.type !== "oauth") throw new Error("Azure OAuth method is missing")
const prompt = method.prompts?.[0]
if (!prompt || prompt.type !== "text") throw new Error("Azure Resource ID prompt is missing")
expect(prompt.validate?.("not/a/resource")).toBe(
"Enter an Azure Resource Name like my-models or a full Resource ID",
)
expect(prompt.validate?.("legacy-resource-name")).toBeUndefined()
const authorization = await method.authorize({
resourceName:
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource/",
})
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
expect(await authorization.callback()).toMatchObject({
type: "success",
accountId:
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource",
})
})
test("uses the first valid Azure account environment value", async () => {
process.env.AZURE_RESOURCE_ID = "not/a/resource"
process.env.AZURE_RESOURCE_NAME = "test-resource"
const hooks = createAzureAuthHooks({
tokenCommand: async () => ({ stdout: tokenOutput("connect-token"), stderr: "", exitCode: 0 }),
})
const method = hooks.auth?.methods.find((method) => method.type === "oauth")
if (!method || method.type !== "oauth") throw new Error("Azure OAuth method is missing")
expect(method.prompts).toEqual([])
const authorization = await method.authorize()
if (authorization.method !== "auto") throw new Error("Unexpected Azure authorization method")
expect(await authorization.callback()).toMatchObject({
type: "success",
accountId: "test-resource",
})
})
})
@@ -3,7 +3,7 @@ import { mkdir, unlink } from "fs/promises"
import path from "path"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect, Layer } from "effect"
import { Effect, Predicate } from "effect"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -82,7 +82,25 @@ const paid = (providers: Record<string, { models: Record<string, { cost: { input
return Object.values(item.models).filter((model) => model.cost.input > 0).length
}
const languageBaseURL = (language: unknown) => (language as { config: { baseURL: string } }).config.baseURL
function languageConfig(language: unknown) {
if (!Predicate.isObject(language)) throw new Error("Expected an AI SDK language model")
if (!Predicate.isObject(language["config"])) throw new Error("Expected an AI SDK language model config")
return language["config"]
}
function languageBaseURL(language: unknown) {
const baseURL = languageConfig(language)["baseURL"]
if (!Predicate.isString(baseURL)) throw new Error("Expected an AI SDK base URL")
return baseURL
}
function languageURL(language: unknown, path: string) {
const url = languageConfig(language)["url"]
if (!Predicate.isFunction(url)) throw new Error("Expected an AI SDK URL builder")
const result: unknown = Reflect.apply(url, undefined, [{ path }])
if (!Predicate.isString(result)) throw new Error("Expected an AI SDK URL")
return result
}
const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node])))
const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true }))
@@ -800,6 +818,31 @@ it.instance("getSmallModel skips inferred models for Azure", () =>
}),
)
it.instance(
"Azure OpenAI resolves an ARM Resource ID to its resource name",
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.azure, ModelV2.ID.make("gpt-5-mini"))
expect(languageURL(yield* provider.getLanguage(model), "/responses")).toBe(
"https://test-resource.openai.azure.com/openai/v1/responses?api-version=v1",
)
}),
{
config: {
provider: {
azure: {
options: {
resourceID:
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource",
},
},
},
},
init: () => setProcessEnv("AZURE_API_KEY", "test-key"),
},
)
it.instance("getSmallModel skips inferred models for Azure Cognitive Services", () =>
Effect.gen(function* () {
yield* set("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "test-resource")
@@ -809,6 +852,48 @@ it.instance("getSmallModel skips inferred models for Azure Cognitive Services",
}),
)
it.instance(
"Azure Cognitive Services resolves a Foundry project endpoint by model shape",
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const opus = yield* provider.getModel(
ProviderV2.ID.make("azure-cognitive-services"),
ModelV2.ID.make("claude-opus-4-5"),
)
expect(languageBaseURL(yield* provider.getLanguage(opus))).toBe(
"https://oauth-resource.services.ai.azure.com/anthropic/v1",
)
const kimi = yield* provider.getModel(
ProviderV2.ID.make("azure-cognitive-services"),
ModelV2.ID.make("kimi-k2.6"),
)
expect(languageURL(yield* provider.getLanguage(kimi), "/chat/completions")).toBe(
"https://oauth-resource.services.ai.azure.com/models/chat/completions",
)
const gpt = yield* provider.getModel(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("gpt-5.1"))
expect(languageURL(yield* provider.getLanguage(gpt), "/responses")).toBe(
"https://oauth-resource.cognitiveservices.azure.com/openai/v1/responses",
)
}),
{
config: {
provider: {
"azure-cognitive-services": {
options: {
baseURL: "",
resourceName: "https://oauth-resource.services.ai.azure.com/api/projects/oauth-project",
},
},
},
},
init: () => setProcessEnv("AZURE_COGNITIVE_SERVICES_API_KEY", "test-key"),
},
)
it.instance(
"getSmallModel respects config small_model override",
Effect.gen(function* () {
+56 -22
View File
@@ -414,14 +414,12 @@ If tool calls aren't working well, pick a loaded model with strong tool-calling
If you encounter "I'm sorry, but I cannot assist with that request" errors, try changing the content filter from **DefaultV2** to **Default** in your Azure resource.
:::
1. Head over to the [Azure portal](https://portal.azure.com/) and create an **Azure OpenAI** resource. You'll need:
- **Resource name**: This becomes part of your API endpoint (`https://RESOURCE_NAME.openai.azure.com/`)
- **API key**: Either `KEY 1` or `KEY 2` from your resource
1. Head over to the [Azure portal](https://portal.azure.com/) and create an **Azure OpenAI** resource. Copy its **Resource name** and, if you want to use API key authentication, either key from **Keys and Endpoint**.
2. Go to [Azure AI Foundry](https://ai.azure.com/) and deploy a model.
2. In [Microsoft Foundry](https://ai.azure.com/), deploy a model to the resource.
:::note
The deployment name must match the model name for opencode to work properly.
The deployment name must exactly match the OpenCode model ID.
:::
3. Run the `/connect` command and search for **Azure**.
@@ -430,28 +428,54 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
/connect
```
4. Enter your API key.
4. Choose an authentication method.
- **API key**: Paste either `KEY 1` or `KEY 2` from your resource.
- **Microsoft Entra ID (Azure CLI)**: [Install the Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli), run `az login`, then assign the signed-in identity the [`Cognitive Services OpenAI User`](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/role-based-access-control) role on the Azure OpenAI resource. `Owner` or `Contributor` alone does not grant inference access.
```txt
API key
Select auth method
│ API key
│ Microsoft Entra ID (Azure CLI)
```
5. Enter the Resource name. If you chose API key authentication, enter the key when prompted.
```txt
┌ Enter Azure Resource Name or Resource ID
│ RESOURCE_NAME
└ enter
```
5. Set your resource name as an environment variable:
With Microsoft Entra ID, OpenCode finds the resource and its deployments in the active Azure CLI subscription. If a different subscription is active, select the correct one and reconnect:
```bash
AZURE_RESOURCE_NAME=XXX opencode
az account set --subscription NAME_OR_ID
```
You can enter the full Resource ID instead when subscription-wide discovery is unavailable.
6. Optional: set the Resource name as an environment variable to skip the resource prompt during `/connect`.
```bash
AZURE_RESOURCE_NAME=RESOURCE_NAME opencode
```
Or add it to your bash profile:
```bash title="~/.bash_profile"
export AZURE_RESOURCE_NAME=XXX
export AZURE_RESOURCE_NAME=RESOURCE_NAME
```
6. Run the `/models` command to select your deployed model.
Or set the complete Resource ID to bypass subscription-wide discovery:
```bash
AZURE_RESOURCE_ID=/subscriptions/SUBSCRIPTION_ID/resourceGroups/RESOURCE_GROUP/providers/Microsoft.CognitiveServices/accounts/RESOURCE_NAME opencode
```
7. Run the `/models` command to select your deployed model. With Microsoft Entra ID, OpenCode shows only successful deployments from the configured resource.
```txt
/models
@@ -461,14 +485,12 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
### Azure Cognitive Services
1. Head over to the [Azure portal](https://portal.azure.com/) and create an **Azure OpenAI** resource. You'll need:
- **Resource name**: This becomes part of your API endpoint (`https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/`)
- **API key**: Either `KEY 1` or `KEY 2` from your resource
1. In [Microsoft Foundry](https://ai.azure.com/), create a **Foundry resource** and a project. Copy the **Project endpoint** from the project's overview page. It looks like `https://RESOURCE_NAME.services.ai.azure.com/api/projects/PROJECT_NAME`. If you want to use API key authentication, copy the API key shown there too.
2. Go to [Azure AI Foundry](https://ai.azure.com/) and deploy a model.
2. Deploy a model from the Foundry model catalog.
:::note
The deployment name must match the model name for opencode to work properly.
OpenCode matches exact deployment or Foundry model names to its model catalog; it does not rewrite model IDs. If a deployment cannot be matched automatically, use the exact OpenCode model ID as its deployment name, for example `gpt-5-mini` or `claude-haiku-4-5`.
:::
3. Run the `/connect` command and search for **Azure Cognitive Services**.
@@ -477,28 +499,40 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
/connect
```
4. Enter your API key.
4. Choose an authentication method.
- **API key**: Paste the API key copied with the Project endpoint.
- **Microsoft Entra ID (Azure CLI)**: [Install the Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli), run `az login`, then assign the signed-in identity the [`Foundry User`](https://learn.microsoft.com/en-us/azure/foundry/concepts/rbac-foundry) role on the Foundry resource. `Owner` or `Contributor` alone does not grant inference access.
```txt
API key
Select auth method
│ API key
│ Microsoft Entra ID (Azure CLI)
```
5. Enter the Project endpoint copied in step 1. If you chose API key, enter your API key when prompted.
```txt
┌ Enter Microsoft Foundry Project Endpoint
│ https://RESOURCE_NAME.services.ai.azure.com/api/projects/PROJECT_NAME
└ enter
```
5. Set your resource name as an environment variable:
6. Optional: set the Project endpoint as an environment variable to skip this prompt during `/connect`.
```bash
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX opencode
AZURE_AI_PROJECT_ENDPOINT=https://RESOURCE_NAME.services.ai.azure.com/api/projects/PROJECT_NAME opencode
```
Or add it to your bash profile:
```bash title="~/.bash_profile"
export AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX
export AZURE_AI_PROJECT_ENDPOINT=https://RESOURCE_NAME.services.ai.azure.com/api/projects/PROJECT_NAME
```
6. Run the `/models` command to select your deployed model.
7. Run the `/models` command to select a model. OpenCode filters the catalog to deployments exposed by the configured Foundry project.
```txt
/models