Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4abac2a
feat(opencode): added oauth to azure through MS Entra ID and az cli
OpeOginni Jun 8, 2026
d862d02
docs: added more docs on new methods to connect azure and az cognitive
OpeOginni Jun 8, 2026
dffa104
Merge branch 'dev' into feat/azure-oauth
OpeOginni Jun 8, 2026
2b53cf0
Merge branch 'dev' into feat/azure-oauth
OpeOginni Jun 16, 2026
db43ee0
docs: revert localized provider updates
OpeOginni Jun 16, 2026
fdf14a8
chore: improved azure oauth method name and ran format
OpeOginni Jun 16, 2026
fa40659
fix: Preserve model-specific endpoints for Azure Cognitive Services
OpeOginni Jun 22, 2026
fcc21f3
fix: Remove the Anthropic API-key header when using bearer
OpeOginni Jun 22, 2026
3cf9cfb
docs: display to use the Foundry inference role for Cognitive Services
OpeOginni Jun 22, 2026
be5f5db
fix: prefer azure cli expires_on for token cache
OpeOginni Jun 22, 2026
c0ecd5d
fix: validate azure cli token expiry parsing
OpeOginni Jun 22, 2026
5613ebb
docs: fixed description of azure oauth
OpeOginni Jun 22, 2026
7adfe50
fix(provider): resolve azure cognitive services resource vars
OpeOginni Jun 22, 2026
e8d955a
fix(provider): resolve azure cognitive services oauth endpoints
OpeOginni Jun 26, 2026
484b662
Merge branch 'dev' into feat/azure-oauth
OpeOginni Jun 26, 2026
92aa12a
fix(provider): preserve cognitive model endpoints in Oauth connection
OpeOginni Jun 29, 2026
49ca926
chore: improve comment
OpeOginni Jun 29, 2026
365a95c
chore: improve comment v2 & moved delete of azureOpenAICompatibleBaseURL
OpeOginni Jun 29, 2026
24479e2
fix: run az cli command/check before storing during azure provider
OpeOginni Jul 8, 2026
a41ed1c
Merge branch 'dev' into feat/azure-oauth
OpeOginni Jul 8, 2026
2d16759
Merge branch 'dev' into feat/azure-oauth
OpeOginni Jul 10, 2026
e41e416
chore: merge dev into azure oauth
OpeOginni Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 157 additions & 11 deletions packages/opencode/src/plugin/azure.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,172 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { OAUTH_DUMMY_KEY } from "../auth"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Option, Schema } from "effect"

const AZURE_SCOPE = "https://cognitiveservices.azure.com"
const AZURE_TOKEN_REFRESH_BUFFER = 60_000
const AzureCliToken = Schema.Struct({
accessToken: Schema.String,
expires_on: Schema.optional(Schema.Number),
expiresOn: Schema.optional(Schema.String),
})
const decodeAzureCliToken = Schema.decodeUnknownOption(Schema.fromJsonString(AzureCliToken))

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 azureAuthPlugin({
provider: "azure",
resourceEnv: "AZURE_RESOURCE_NAME",
oauthInstructions:
"Sign in with `az login`. The signed-in Azure identity must have the Cognitive Services OpenAI User role for this resource.",
prompts: process.env.AZURE_RESOURCE_NAME
? []
: [
{
type: "text" as const,
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
},
],
providerOptions: (resourceName) => ({ resourceName }),
})
}

export async function AzureCognitiveServicesAuthPlugin(_input: PluginInput): Promise<Hooks> {
return azureAuthPlugin({
provider: "azure-cognitive-services",
resourceEnv: "AZURE_COGNITIVE_SERVICES_RESOURCE_NAME",
oauthInstructions:
"Sign in with `az login`. The signed-in Azure identity must have the Cognitive Services User or Foundry User role for this resource.",
prompts: process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
? []
: [
{
type: "text" as const,
key: "resourceName",
message: "Enter Azure Cognitive Services Resource Name",
placeholder: "e.g. my-models",
},
],
})
}

function azureAuthPlugin(input: {
provider: string
resourceEnv: string
oauthInstructions: string
prompts: NonNullable<Hooks["auth"]>["methods"][number]["prompts"]
providerOptions?: (resourceName: string) => Record<string, string>
}): Hooks {
const tokenProvider = azureCliTokenProvider()
return {
auth: {
provider: "azure",
provider: input.provider,
async loader(getAuth) {
const auth = await getAuth()
if (auth.type !== "oauth") return {}

const resourceName = process.env[input.resourceEnv] || auth.accountId
return {
...((resourceName && input.providerOptions?.(resourceName)) ?? {}),
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
const currentAuth = await getAuth()
if (currentAuth.type !== "oauth") return fetch(requestInput, init)

const headers = new Headers(requestInput instanceof Request ? requestInput.headers : undefined)
if (init?.headers) {
const entries =
init.headers instanceof Headers
? init.headers.entries()
: Array.isArray(init.headers)
? init.headers
: Object.entries(init.headers as Record<string, string | undefined>)
for (const [key, value] of entries) {
if (value !== undefined) headers.set(key, String(value))
}
}
headers.delete("api-key")
headers.delete("x-api-key")
headers.set("authorization", `Bearer ${await tokenProvider()}`)
headers.set("User-Agent", `opencode/${InstallationVersion}`)

return fetch(requestInput, { ...init, headers })
},
}
},
methods: [
{
type: "api",
label: "API key",
prompts,
prompts: input.prompts,
},
{
type: "oauth",
label: "Microsoft Entra ID (OAuth via az cli)",
prompts: input.prompts,
authorize: async (inputs) => ({
url: "https://learn.microsoft.com/azure/developer/ai/keyless-connections",
instructions: input.oauthInstructions,
method: "auto" as const,
callback: async () => {
await tokenProvider()
return {
type: "success" as const,
access: OAUTH_DUMMY_KEY,
refresh: OAUTH_DUMMY_KEY,
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
accountId: inputs?.resourceName || process.env[input.resourceEnv],
}
},
}),
},
],
},
}
}

function azureCliTokenProvider() {
let cached: { token: string; expires: number } | undefined
return async () => {
if (cached && cached.expires - Date.now() > AZURE_TOKEN_REFRESH_BUFFER) return cached.token

const proc = spawnAzureCliTokenCommand()
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (exitCode !== 0) {
throw new Error(stderr.trim() || "Failed to get Azure access token. Run `az login` and try again.")
}

const decoded = decodeAzureCliToken(stdout)
if (Option.isNone(decoded)) throw new Error("Azure CLI did not return an access token")

cached = {
token: decoded.value.accessToken,
// Azure CLI's expiresOn is a timezone-less local datetime; expires_on avoids DST ambiguity.
expires:
decoded.value.expires_on !== undefined
? decoded.value.expires_on * 1000
: decoded.value.expiresOn
? new Date(decoded.value.expiresOn).getTime()
: Date.now() + 30 * 60 * 1000,
}
return cached.token
}
}

function spawnAzureCliTokenCommand() {
try {
return Bun.spawn(["az", "account", "get-access-token", "--resource", AZURE_SCOPE, "--output", "json"], {
stdout: "pipe",
stderr: "pipe",
})
} catch (error) {
throw new Error("Azure CLI is required for Microsoft Entra ID OAuth. Install `az`, run `az login`, and try again.", {
cause: error,
})
}
}
3 changes: 2 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure"
import { AzureAuthPlugin, AzureCognitiveServicesAuthPlugin } from "./azure"
import { DigitalOceanAuthPlugin } from "./digitalocean"
import { XaiAuthPlugin } from "./xai"
import { SnowflakeCortexAuthPlugin } from "./snowflake-cortex"
Expand Down Expand Up @@ -75,6 +75,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
AzureCognitiveServicesAuthPlugin,
DigitalOceanAuthPlugin,
SnowflakeCortexAuthPlugin,
XaiAuthPlugin,
Expand Down
37 changes: 35 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
return [
provider.options?.resourceName,
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
auth?.type === "oauth" ? auth.accountId : undefined,
env["AZURE_RESOURCE_NAME"],
].find((name) => typeof name === "string" && name.trim() !== "")
})
Expand Down Expand Up @@ -278,17 +279,35 @@ 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 === "oauth" ? auth.accountId : undefined,
Comment thread
OpeOginni marked this conversation as resolved.
env["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME"],
].find((name) => typeof name === "string" && name.trim() !== "")
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* () {
Expand Down Expand Up @@ -1686,6 +1705,19 @@ const layer = Layer.effect(
delete options.fetch
}

if (
model.providerID === "azure-cognitive-services" &&
model.api.npm === "@ai-sdk/azure" &&
!model.api.url &&
typeof options["baseURL"] !== "string" &&
typeof options["azureOpenAICompatibleBaseURL"] === "string" &&
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
}
Expand All @@ -1712,6 +1744,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"] = {
Expand Down
38 changes: 38 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ const paid = (providers: Record<string, { models: Record<string, { cost: { input
}

const languageBaseURL = (language: unknown) => (language as { config: { baseURL: string } }).config.baseURL
const languageURL = (language: unknown, path: string) =>
(language as { config: { url: (options: { path: string }) => string } }).config.url({ path })

const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node])))
const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true }))
Expand Down Expand Up @@ -805,6 +807,42 @@ it.instance("getSmallModel skips inferred models for Azure Cognitive Services",
}),
)

it.instance("Azure Cognitive Services resolves OAuth resource endpoints by model shape", () =>
Effect.gen(function* () {
yield* setProcessEnv(
"OPENCODE_AUTH_CONTENT",
JSON.stringify({
"azure-cognitive-services": {
type: "oauth",
refresh: "refresh-token",
access: "access-token",
expires: Date.now() + 60_000,
accountId: "oauth-resource",
},
}),
)
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",
)
}),
)

it.instance(
"getSmallModel respects config small_model override",
Effect.gen(function* () {
Expand Down
Loading
Loading