mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
remove overengineering
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { Schema } from "effect"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
|
||||
const AZURE_COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default"
|
||||
const AZURE_FOUNDRY_SCOPE = "https://ai.azure.com/.default"
|
||||
const AZURE_TOKEN_REFRESH_BUFFER = 60_000
|
||||
|
||||
const AzureCliToken = Schema.Struct({
|
||||
accessToken: Schema.NonEmptyString,
|
||||
expires_on: Schema.Number,
|
||||
})
|
||||
const decodeAzureCliToken = Schema.decodeUnknownPromise(AzureCliToken)
|
||||
|
||||
type AzureCommand = {
|
||||
quiet(): AzureCommand
|
||||
json(): Promise<unknown>
|
||||
}
|
||||
|
||||
type AzureShell = (strings: TemplateStringsArray, scope: string) => AzureCommand
|
||||
|
||||
export async function AzureAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
return createAzureAuthHooks(input.$)
|
||||
}
|
||||
|
||||
export function createAzureAuthHooks(
|
||||
shell: AzureShell,
|
||||
request: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = fetch,
|
||||
): Hooks {
|
||||
const tokens = new Map<string, { token: string; expires: number }>()
|
||||
async function token(scope: string) {
|
||||
const cached = tokens.get(scope)
|
||||
if (cached && cached.expires - Date.now() > AZURE_TOKEN_REFRESH_BUFFER) return cached.token
|
||||
|
||||
const result = await decodeAzureCliToken(
|
||||
await shell`az account get-access-token --scope ${scope} --output json`.quiet().json(),
|
||||
)
|
||||
const token = { token: result.accessToken, expires: result.expires_on * 1000 }
|
||||
tokens.set(scope, token)
|
||||
return token.token
|
||||
}
|
||||
|
||||
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",
|
||||
async loader(getAuth) {
|
||||
if ((await getAuth()).type !== "oauth") return {}
|
||||
|
||||
return {
|
||||
apiKey: OAUTH_DUMMY_KEY,
|
||||
async fetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const headers = new Headers(input instanceof Request ? input.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(scopeForRequest(input))}`)
|
||||
headers.set("User-Agent", `opencode/${InstallationVersion}`)
|
||||
return request(input, { ...init, headers })
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
prompts,
|
||||
},
|
||||
{
|
||||
type: "oauth",
|
||||
label: "Microsoft Entra ID (Azure CLI)",
|
||||
prompts,
|
||||
async authorize(inputs) {
|
||||
return {
|
||||
url: "",
|
||||
instructions: "Sign in with `az login` before continuing.",
|
||||
method: "auto",
|
||||
callback: async () => {
|
||||
const resourceName = inputs?.resourceName ?? process.env.AZURE_RESOURCE_NAME
|
||||
if (!resourceName) throw new Error("Azure Resource Name is required")
|
||||
|
||||
await token(AZURE_COGNITIVE_SERVICES_SCOPE)
|
||||
return {
|
||||
type: "success",
|
||||
access: OAUTH_DUMMY_KEY,
|
||||
refresh: OAUTH_DUMMY_KEY,
|
||||
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
||||
accountId: resourceName,
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function scopeForRequest(input: RequestInfo | URL) {
|
||||
const url = new URL(input instanceof Request ? input.url : input)
|
||||
if (url.hostname.endsWith(".services.ai.azure.com") && !url.pathname.startsWith("/models")) {
|
||||
return AZURE_FOUNDRY_SCOPE
|
||||
}
|
||||
return AZURE_COGNITIVE_SERVICES_SCOPE
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Option, Predicate, Schema } from "effect"
|
||||
import { OAUTH_DUMMY_KEY } from "../../auth"
|
||||
import { azureConnection, foundryProjectEndpoint } 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_RESOURCE_MANAGER_SCOPE = "https://management.azure.com/.default"
|
||||
export const AZURE_FOUNDRY_PROJECT_ENDPOINT_ENV = "AZURE_AI_PROJECT_ENDPOINT"
|
||||
export const AZURE_RESOURCE_ID_ENV = "AZURE_RESOURCE_ID"
|
||||
export const AZURE_RESOURCE_NAME_ENV = "AZURE_RESOURCE_NAME"
|
||||
|
||||
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))
|
||||
|
||||
type AzureCliCommandResult = {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
type AzureCliCommand = (scope: string, signal?: AbortSignal) => Promise<AzureCliCommandResult>
|
||||
|
||||
export type AzureRequest = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
export type AzureAccessToken = {
|
||||
token: string
|
||||
subscription?: string
|
||||
}
|
||||
|
||||
export type AzureAuthPluginOptions = {
|
||||
tokenCommand?: AzureCliCommand
|
||||
request?: AzureRequest
|
||||
}
|
||||
|
||||
export function createAzureAuth(options: AzureAuthPluginOptions = {}) {
|
||||
const credential = azureCliTokenProvider(options.tokenCommand ?? runAzureCliTokenCommand)
|
||||
const token = async (scope: string, signal?: AbortSignal) => (await credential(scope, signal)).token
|
||||
const request = options.request ?? fetch
|
||||
const configured = connectionFromEnvironment() !== undefined
|
||||
|
||||
return {
|
||||
credential,
|
||||
token,
|
||||
request,
|
||||
auth: {
|
||||
provider: "azure",
|
||||
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: configured ? [] : apiPrompts(),
|
||||
},
|
||||
{
|
||||
type: "oauth",
|
||||
label: "Microsoft Entra ID (Azure CLI)",
|
||||
prompts: configured ? [] : oauthPrompts(),
|
||||
authorize: async (inputs) => ({
|
||||
// Azure CLI owns the interactive sign-in, so OpenCode has no authorization URL to open.
|
||||
url: "",
|
||||
instructions:
|
||||
"Sign in with `az login`. Assign Cognitive Services OpenAI User for Azure OpenAI resources or Foundry User for Foundry resources.",
|
||||
method: "auto",
|
||||
callback: async () => {
|
||||
const input = inputs?.connection ?? inputs?.projectEndpoint ?? inputs?.resourceID ?? inputs?.resourceName
|
||||
const connection = input ? azureConnection(input) : connectionFromEnvironment()
|
||||
if (!connection) {
|
||||
throw new Error("Enter an Azure Resource Name, full Resource ID, or Foundry Project endpoint")
|
||||
}
|
||||
|
||||
await token(connection.projectEndpoint ? AZURE_FOUNDRY_SCOPE : AZURE_RESOURCE_MANAGER_SCOPE)
|
||||
return {
|
||||
type: "success",
|
||||
access: OAUTH_DUMMY_KEY,
|
||||
refresh: OAUTH_DUMMY_KEY,
|
||||
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
||||
// OAuth exposes accountId as the durable discriminator for connection-specific credentials.
|
||||
accountId: connection.projectEndpoint ?? connection.resourceID ?? connection.resourceName,
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
} satisfies NonNullable<Hooks["auth"]>,
|
||||
}
|
||||
}
|
||||
|
||||
function apiPrompts(): NonNullable<Hooks["auth"]>["methods"][number]["prompts"] {
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
key: "connectionType",
|
||||
message: "Select Azure connection type",
|
||||
options: [
|
||||
{
|
||||
label: "Foundry Project endpoint",
|
||||
value: "projectEndpoint",
|
||||
hint: "For a Microsoft Foundry project",
|
||||
},
|
||||
{
|
||||
label: "Azure Resource name",
|
||||
value: "resourceName",
|
||||
hint: "For a standalone Azure OpenAI resource",
|
||||
},
|
||||
{
|
||||
label: "Azure Resource ID",
|
||||
value: "resourceID",
|
||||
hint: "Full ARM resource ID",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "projectEndpoint",
|
||||
message: "Enter Foundry Project endpoint",
|
||||
placeholder: "https://RESOURCE.services.ai.azure.com/api/projects/PROJECT",
|
||||
when: { key: "connectionType", op: "eq", value: "projectEndpoint" },
|
||||
validate: (value: string) =>
|
||||
foundryProjectEndpoint(value)
|
||||
? undefined
|
||||
: "Enter a Project endpoint like https://RESOURCE.services.ai.azure.com/api/projects/PROJECT",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "resourceName",
|
||||
message: "Enter Azure Resource name",
|
||||
placeholder: "my-resource",
|
||||
when: { key: "connectionType", op: "eq", value: "resourceName" },
|
||||
validate: (value: string) => {
|
||||
const connection = azureConnection(value)
|
||||
return connection && !connection.resourceID && !connection.projectEndpoint
|
||||
? undefined
|
||||
: "Enter a Resource name like my-resource"
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "resourceID",
|
||||
message: "Enter Azure Resource ID",
|
||||
placeholder: "/subscriptions/.../providers/Microsoft.CognitiveServices/accounts/RESOURCE",
|
||||
when: { key: "connectionType", op: "eq", value: "resourceID" },
|
||||
validate: (value: string) => (azureConnection(value)?.resourceID ? undefined : "Enter the full ARM Resource ID"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function oauthPrompts(): NonNullable<Hooks["auth"]>["methods"][number]["prompts"] {
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
key: "connection",
|
||||
message: "Enter Azure Resource name",
|
||||
placeholder: "my-resource",
|
||||
validate: (value: string) =>
|
||||
azureConnection(value)
|
||||
? undefined
|
||||
: "Enter an Azure Resource name, full Resource ID, or Foundry Project endpoint",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function connectionFromEnvironment() {
|
||||
return [AZURE_RESOURCE_ID_ENV, AZURE_FOUNDRY_PROJECT_ENDPOINT_ENV, AZURE_RESOURCE_NAME_ENV]
|
||||
.map((name) => azureConnection(process.env[name]))
|
||||
.find(Predicate.isNotUndefined)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { Provider } from "@opencode-ai/sdk/v2"
|
||||
import { Option, Schema } from "effect"
|
||||
import { AZURE_RESOURCE_MANAGER_SCOPE, type AzureAccessToken, type AzureRequest } from "./auth"
|
||||
import { AzureResourceID } from "./schema"
|
||||
|
||||
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))
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Auth } from "@opencode-ai/sdk/v2"
|
||||
import { Predicate } from "effect"
|
||||
import {
|
||||
AZURE_FOUNDRY_SCOPE,
|
||||
AZURE_FOUNDRY_PROJECT_ENDPOINT_ENV,
|
||||
AZURE_RESOURCE_MANAGER_SCOPE,
|
||||
AZURE_RESOURCE_ID_ENV,
|
||||
AZURE_RESOURCE_NAME_ENV,
|
||||
createAzureAuth,
|
||||
type AzureAuthPluginOptions,
|
||||
} from "./auth"
|
||||
import { deployedModels, listAzureDeployments, resolveAzureResourceID } from "./discovery"
|
||||
import { azureConnection } from "./schema"
|
||||
|
||||
const AZURE_DISCOVERY_TIMEOUT = 5_000
|
||||
|
||||
export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
return createAzureAuthHooks()
|
||||
}
|
||||
|
||||
export function createAzureAuthHooks(options: AzureAuthPluginOptions = {}): Hooks {
|
||||
const azure = createAzureAuth(options)
|
||||
|
||||
return {
|
||||
auth: azure.auth,
|
||||
provider: {
|
||||
id: "azure",
|
||||
async models(info, context) {
|
||||
const apiKey = process.env.AZURE_API_KEY
|
||||
const auth: Auth | undefined = context.auth ?? (apiKey ? { type: "api", key: apiKey } : undefined)
|
||||
const connection = [
|
||||
auth?.type === "oauth" ? auth.accountId : undefined,
|
||||
auth?.type === "api" ? auth.metadata?.connection : undefined,
|
||||
auth?.type === "api" ? auth.metadata?.resourceID : undefined,
|
||||
auth?.type === "api" ? auth.metadata?.projectEndpoint : undefined,
|
||||
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
|
||||
process.env[AZURE_RESOURCE_ID_ENV],
|
||||
process.env[AZURE_FOUNDRY_PROJECT_ENDPOINT_ENV],
|
||||
process.env[AZURE_RESOURCE_NAME_ENV],
|
||||
]
|
||||
.map(azureConnection)
|
||||
.find(Predicate.isNotUndefined)
|
||||
if (!connection || !auth) return info.models
|
||||
|
||||
const signal = AbortSignal.timeout(AZURE_DISCOVERY_TIMEOUT)
|
||||
if (connection.projectEndpoint) {
|
||||
const headers = new Headers()
|
||||
if (auth.type === "api") headers.set("api-key", auth.key)
|
||||
if (auth.type === "oauth") {
|
||||
const token = await azure.token(AZURE_FOUNDRY_SCOPE, signal).catch(() => undefined)
|
||||
if (!token) return {}
|
||||
headers.set("authorization", `Bearer ${token}`)
|
||||
}
|
||||
if (auth.type !== "api" && auth.type !== "oauth") return info.models
|
||||
const deployments = await listAzureDeployments(
|
||||
`${connection.projectEndpoint}/deployments?api-version=v1&deploymentType=ModelDeployment`,
|
||||
headers,
|
||||
azure.request,
|
||||
(deployment) => deployment.type === "ModelDeployment",
|
||||
signal,
|
||||
).catch(() => [])
|
||||
return deployedModels(info.models, deployments)
|
||||
}
|
||||
if (auth.type !== "oauth") return info.models
|
||||
|
||||
const resourceID =
|
||||
connection.resourceID ??
|
||||
(await resolveAzureResourceID(connection.resourceName, azure.credential, azure.request, signal).catch(
|
||||
() => undefined,
|
||||
))
|
||||
if (!resourceID) return {}
|
||||
|
||||
const token = await azure.token(AZURE_RESOURCE_MANAGER_SCOPE, signal).catch(() => undefined)
|
||||
if (!token) return {}
|
||||
const deployments = await listAzureDeployments(
|
||||
`https://management.azure.com${resourceID}/deployments?api-version=2024-10-01`,
|
||||
new Headers({ authorization: `Bearer ${token}` }),
|
||||
azure.request,
|
||||
(deployment) => deployment.properties?.provisioningState === "Succeeded",
|
||||
signal,
|
||||
).catch(() => [])
|
||||
return deployedModels(info.models, deployments)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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)
|
||||
|
||||
function azureResource(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 azureConnection(input: unknown) {
|
||||
const resource = azureResource(input)
|
||||
if (resource) return { ...resource, projectEndpoint: undefined }
|
||||
|
||||
const projectEndpoint = foundryProjectEndpoint(input)
|
||||
if (!projectEndpoint) return undefined
|
||||
const resourceName = azureResourceName(projectEndpoint)
|
||||
if (!resourceName) return undefined
|
||||
return { projectEndpoint, resourceID: undefined, resourceName }
|
||||
}
|
||||
|
||||
export function azureResourceName(input: unknown) {
|
||||
const resource = azureResource(input)
|
||||
if (resource) return resource.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(/\/$/, "")}`
|
||||
}
|
||||
@@ -8,7 +8,6 @@ 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"
|
||||
@@ -19,7 +18,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, Predicate, Schema, Types } from "effect"
|
||||
import { Effect, Layer, Context, Schema, Types } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectPromise } from "@/effect/promise"
|
||||
@@ -241,28 +240,21 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
azure: Effect.fnUntraced(function* (provider: Info) {
|
||||
const env = yield* dep.env()
|
||||
const auth = yield* dep.auth(provider.id)
|
||||
const resource = [
|
||||
provider.options?.resourceID,
|
||||
provider.options?.projectEndpoint,
|
||||
provider.options?.resourceName,
|
||||
auth?.type === "api" ? auth.metadata?.connection : undefined,
|
||||
auth?.type === "api" ? auth.metadata?.resourceID : undefined,
|
||||
auth?.type === "api" ? auth.metadata?.projectEndpoint : undefined,
|
||||
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
|
||||
auth?.type === "oauth" ? auth.accountId : undefined,
|
||||
env["AZURE_RESOURCE_ID"],
|
||||
env["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
env["AZURE_RESOURCE_NAME"],
|
||||
]
|
||||
.map(azureResourceName)
|
||||
.find(Predicate.isString)
|
||||
const resource = iife(() => {
|
||||
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() !== "")
|
||||
})
|
||||
|
||||
if (!resource && !provider.options?.baseURL) {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel() {
|
||||
throw new Error(
|
||||
"Azure Resource Name, Resource ID, or Foundry Project endpoint is missing; reconnect the Azure provider",
|
||||
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { afterEach, 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 type { Provider } from "@opencode-ai/sdk"
|
||||
import type { Auth } from "@opencode-ai/sdk/v2"
|
||||
import { OAUTH_DUMMY_KEY } from "../../src/auth"
|
||||
import { createAzureAuthHooks } from "../../src/plugin/azure"
|
||||
import { azureConnection } from "../../src/plugin/azure/schema"
|
||||
|
||||
const resourceName = process.env.AZURE_RESOURCE_NAME
|
||||
|
||||
afterEach(() => {
|
||||
if (resourceName === undefined) delete process.env.AZURE_RESOURCE_NAME
|
||||
else process.env.AZURE_RESOURCE_NAME = resourceName
|
||||
})
|
||||
|
||||
const oauth: Auth = {
|
||||
type: "oauth",
|
||||
access: OAUTH_DUMMY_KEY,
|
||||
refresh: OAUTH_DUMMY_KEY,
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
accountId: "test-resource",
|
||||
}
|
||||
|
||||
const provider: Provider = {
|
||||
id: "azure",
|
||||
@@ -15,634 +29,119 @@ const provider: Provider = {
|
||||
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 azureApiKey = process.env.AZURE_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_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 (azureApiKey === undefined) delete process.env.AZURE_API_KEY
|
||||
else process.env.AZURE_API_KEY = azureApiKey
|
||||
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 modelHook(hooks: Hooks) {
|
||||
if (!hooks.provider?.models) throw new Error("Azure provider model hook is missing")
|
||||
return hooks.provider.models
|
||||
}
|
||||
|
||||
function oauthMethod(hooks: Hooks) {
|
||||
const method = hooks.auth?.methods.find((method) => method.type === "oauth")
|
||||
if (!method || method.type !== "oauth") throw new Error("Azure OAuth method is missing")
|
||||
return method
|
||||
}
|
||||
|
||||
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")
|
||||
if (typeof result !== "function") 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")
|
||||
const response: unknown = await Reflect.apply(result, undefined, [input, init])
|
||||
if (!(response instanceof Response)) throw new Error("Azure custom fetch returned an invalid 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 })
|
||||
},
|
||||
function azureShell(scopes: string[]) {
|
||||
return (_strings: TemplateStringsArray, scope: string) => {
|
||||
scopes.push(scope)
|
||||
const output = {
|
||||
quiet: () => output,
|
||||
json: async () => ({
|
||||
accessToken: `${scope}-token`,
|
||||
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
}),
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
describe("plugin.azure", () => {
|
||||
test("exposes one Azure auth surface for API key and Entra ID", () => {
|
||||
const hooks = createAzureAuthHooks()
|
||||
test("keeps the existing API-key method and adds Entra ID", () => {
|
||||
delete process.env.AZURE_RESOURCE_NAME
|
||||
const hooks = createAzureAuthHooks(azureShell([]))
|
||||
|
||||
expect(hooks.auth?.provider).toBe("azure")
|
||||
expect(hooks.provider).toBeUndefined()
|
||||
expect(hooks.auth?.methods.map((method) => [method.type, method.label])).toEqual([
|
||||
["api", "API key"],
|
||||
["oauth", "Microsoft Entra ID (Azure CLI)"],
|
||||
])
|
||||
expect(hooks.provider?.id).toBe("azure")
|
||||
expect(hooks.auth?.methods.map((method) => method.prompts?.[0])).toEqual([
|
||||
expect.objectContaining({ type: "select", key: "connectionType", message: "Select Azure connection type" }),
|
||||
expect.objectContaining({ type: "text", key: "connection", message: "Enter Azure Resource name" }),
|
||||
])
|
||||
})
|
||||
|
||||
test("normalizes every supported Azure connection locator", () => {
|
||||
expect(azureConnection("test-resource")).toMatchObject({ resourceName: "test-resource" })
|
||||
expect(
|
||||
azureConnection(
|
||||
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource/",
|
||||
),
|
||||
).toEqual({
|
||||
resourceID:
|
||||
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource",
|
||||
resourceName: "test-resource",
|
||||
projectEndpoint: undefined,
|
||||
})
|
||||
expect(azureConnection("https://test-resource.services.ai.azure.com/api/projects/test-project/")).toEqual({
|
||||
projectEndpoint: "https://test-resource.services.ai.azure.com/api/projects/test-project",
|
||||
resourceID: undefined,
|
||||
resourceName: "test-resource",
|
||||
})
|
||||
})
|
||||
|
||||
test("maps exact Foundry project model metadata to OpenCode model IDs", async () => {
|
||||
const scopes: string[] = []
|
||||
const requests: string[] = []
|
||||
const hooks = createAzureAuthHooks({
|
||||
request: async (input) => {
|
||||
const url = input instanceof Request ? input.url : input.toString()
|
||||
requests.push(url)
|
||||
if (url.includes("/api/projects/")) {
|
||||
return Response.json({
|
||||
value: [
|
||||
{
|
||||
name: "production-gpt",
|
||||
type: "ModelDeployment",
|
||||
modelName: "gpt-5-mini",
|
||||
},
|
||||
{
|
||||
name: "production-deepseek",
|
||||
type: "ModelDeployment",
|
||||
modelName: "DeepSeek-V4-Flash",
|
||||
},
|
||||
{
|
||||
name: "custom-instruct",
|
||||
type: "ModelDeployment",
|
||||
modelName: "custom-instruct",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
},
|
||||
tokenCommand: async (scope) => {
|
||||
scopes.push(scope)
|
||||
return { stdout: tokenOutput(`${scope}-token`), stderr: "", exitCode: 0 }
|
||||
},
|
||||
})
|
||||
const list = modelHook(hooks)
|
||||
|
||||
const result = await list(
|
||||
{ ...provider, models: models("gpt-5-mini", "deepseek-v4-flash", "custom") },
|
||||
{ auth: oauth },
|
||||
)
|
||||
|
||||
expect(Object.keys(result)).toEqual(["gpt-5-mini", "deepseek-v4-flash"])
|
||||
expect(result["gpt-5-mini"].api.id).toBe("production-gpt")
|
||||
expect(result["deepseek-v4-flash"].api.id).toBe("production-deepseek")
|
||||
expect(scopes).toEqual(["https://ai.azure.com/.default"])
|
||||
expect(requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("lists project deployments with an API key without invoking Azure CLI", async () => {
|
||||
let cliCalls = 0
|
||||
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: "claude-haiku-4-5", type: "ModelDeployment" }],
|
||||
})
|
||||
},
|
||||
tokenCommand: async () => {
|
||||
cliCalls++
|
||||
throw new Error("Azure CLI should not be used for API key auth")
|
||||
},
|
||||
})
|
||||
const list = modelHook(hooks)
|
||||
|
||||
const result = await list(
|
||||
{ ...provider, models: models("gpt-5-mini", "claude-haiku-4-5") },
|
||||
{
|
||||
auth: {
|
||||
type: "api",
|
||||
key: "project-key",
|
||||
metadata: {
|
||||
connectionType: "projectEndpoint",
|
||||
projectEndpoint: "https://test-resource.services.ai.azure.com/api/projects/test-project",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
const legacy = await list(
|
||||
{ ...provider, models: models("gpt-5-mini", "claude-haiku-4-5") },
|
||||
{
|
||||
auth: {
|
||||
type: "api",
|
||||
key: "project-key",
|
||||
metadata: {
|
||||
resourceName: "https://test-resource.services.ai.azure.com/api/projects/test-project",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(Object.keys(result)).toEqual(["claude-haiku-4-5"])
|
||||
expect(Object.keys(legacy)).toEqual(["claude-haiku-4-5"])
|
||||
expect(cliCalls).toBe(0)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests.map((request) => request.headers.get("api-key"))).toEqual(["project-key", "project-key"])
|
||||
expect(requests.map((request) => request.headers.get("authorization"))).toEqual([null, null])
|
||||
})
|
||||
|
||||
test("keeps the app usable when Foundry authentication is unavailable", async () => {
|
||||
const hooks = createAzureAuthHooks({
|
||||
tokenCommand: async () => ({ stdout: "", stderr: "Run `az login`", exitCode: 1 }),
|
||||
})
|
||||
const list = modelHook(hooks)
|
||||
|
||||
expect(await list({ ...provider, models: models("gpt-5-mini") }, { auth: oauth })).toEqual({})
|
||||
})
|
||||
|
||||
test("keeps the app usable when Azure discovery times out", async () => {
|
||||
const hooks = createAzureAuthHooks({
|
||||
request: async () => {
|
||||
throw new DOMException("The operation timed out", "TimeoutError")
|
||||
},
|
||||
})
|
||||
const list = modelHook(hooks)
|
||||
|
||||
expect(
|
||||
await list(
|
||||
{ ...provider, models: models("gpt-5-mini") },
|
||||
expect(hooks.auth?.methods[0]).toEqual({
|
||||
type: "api",
|
||||
label: "API key",
|
||||
prompts: [
|
||||
{
|
||||
auth: {
|
||||
type: "api",
|
||||
key: "project-key",
|
||||
metadata: { connection: oauth.accountId ?? "" },
|
||||
},
|
||||
type: "text",
|
||||
key: "resourceName",
|
||||
message: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
},
|
||||
),
|
||||
).toEqual({})
|
||||
],
|
||||
})
|
||||
expect(hooks.auth?.methods[1].prompts).toEqual(hooks.auth?.methods[0].prompts)
|
||||
})
|
||||
|
||||
test("lists succeeded Azure deployments without changing their deployment IDs", async () => {
|
||||
test("checks Azure CLI and stores the resource name", 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 = modelHook(hooks)
|
||||
|
||||
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 = modelHook(hooks)
|
||||
|
||||
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 = modelHook(hooks)
|
||||
|
||||
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 = modelHook(hooks)
|
||||
|
||||
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 = modelHook(hooks)
|
||||
|
||||
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 = createAzureAuthHooks({
|
||||
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("accepts a Foundry project endpoint and checks Azure CLI before storing it", async () => {
|
||||
const scopes: string[] = []
|
||||
const hooks = createAzureAuthHooks({
|
||||
tokenCommand: async (scope) => {
|
||||
scopes.push(scope)
|
||||
return { stdout: tokenOutput("connect-token"), stderr: "", exitCode: 0 }
|
||||
},
|
||||
})
|
||||
const method = oauthMethod(hooks)
|
||||
const prompt = method.prompts?.find((prompt) => prompt.key === "connection")
|
||||
if (!prompt || prompt.type !== "text") throw new Error("Azure Resource name prompt is missing")
|
||||
|
||||
expect(prompt.validate?.("not/a/resource")).toBe(
|
||||
"Enter an Azure Resource name, full Resource ID, or Foundry Project endpoint",
|
||||
)
|
||||
expect(prompt.validate?.("https://connected-resource.services.ai.azure.com/api/projects/connected-project")).toBe(
|
||||
undefined,
|
||||
)
|
||||
|
||||
const authorization = await method.authorize({
|
||||
connection: "https://connected-resource.services.ai.azure.com/api/projects/connected-project/",
|
||||
})
|
||||
const hooks = createAzureAuthHooks(azureShell(scopes))
|
||||
const authorization = await oauthMethod(hooks).authorize({ resourceName: "test-resource" })
|
||||
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({
|
||||
expect(await authorization.callback()).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 = oauthMethod(hooks)
|
||||
const prompt = method.prompts?.find((prompt) => prompt.key === "connection")
|
||||
if (!prompt || prompt.type !== "text") throw new Error("Azure Resource name prompt is missing")
|
||||
|
||||
expect(prompt.validate?.("not/a/resource")).toBe(
|
||||
"Enter an Azure Resource name, full Resource ID, or Foundry Project endpoint",
|
||||
)
|
||||
const authorization = await method.authorize({
|
||||
connection:
|
||||
"/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 = oauthMethod(hooks)
|
||||
|
||||
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",
|
||||
})
|
||||
expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default"])
|
||||
})
|
||||
|
||||
test("does not change API-key loading", async () => {
|
||||
const scopes: string[] = []
|
||||
const hooks = createAzureAuthHooks(azureShell(scopes))
|
||||
|
||||
expect(await loader(hooks)(async () => ({ type: "api", key: "test-key" }), provider)).toEqual({})
|
||||
expect(scopes).toEqual([])
|
||||
})
|
||||
|
||||
test("uses Azure CLI bearer tokens for Azure inference endpoints", async () => {
|
||||
const scopes: string[] = []
|
||||
const requests: Headers[] = []
|
||||
const hooks = createAzureAuthHooks(azureShell(scopes), async (_input, init) => {
|
||||
requests.push(new Headers(init?.headers))
|
||||
return new Response(null, { status: 200 })
|
||||
})
|
||||
const options = await loader(hooks)(async () => oauth, provider)
|
||||
const request = customFetch(options)
|
||||
|
||||
await request("https://test-resource.openai.azure.com/openai/v1/responses", {
|
||||
headers: { "api-key": OAUTH_DUMMY_KEY, "x-keep": "yes" },
|
||||
})
|
||||
await request("https://test-resource.services.ai.azure.com/models/chat/completions", {
|
||||
headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}` },
|
||||
})
|
||||
await request("https://test-resource.services.ai.azure.com/anthropic/v1/messages", {
|
||||
headers: { "x-api-key": OAUTH_DUMMY_KEY },
|
||||
})
|
||||
|
||||
expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default", "https://ai.azure.com/.default"])
|
||||
expect(requests.map((headers) => headers.get("authorization"))).toEqual([
|
||||
"Bearer https://cognitiveservices.azure.com/.default-token",
|
||||
"Bearer https://cognitiveservices.azure.com/.default-token",
|
||||
"Bearer https://ai.azure.com/.default-token",
|
||||
])
|
||||
expect(requests[0].get("api-key")).toBeNull()
|
||||
expect(requests[0].get("x-keep")).toBe("yes")
|
||||
expect(requests[2].get("x-api-key")).toBeNull()
|
||||
expect(requests.every((headers) => headers.get("user-agent")?.startsWith("opencode/"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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, Predicate } from "effect"
|
||||
import { Effect, Layer } 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,25 +82,7 @@ const paid = (providers: Record<string, { models: Record<string, { cost: { input
|
||||
return Object.values(item.models).filter((model) => model.cost.input > 0).length
|
||||
}
|
||||
|
||||
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 languageBaseURL = (language: unknown) => (language as { config: { baseURL: string } }).config.baseURL
|
||||
|
||||
const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node])))
|
||||
const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true }))
|
||||
@@ -818,90 +800,38 @@ it.instance("getSmallModel skips inferred models for Azure", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("Azure is the only built-in Azure auth provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* plugin.list()
|
||||
const providers = hooks.flatMap((hook) => (hook.auth ? [hook.auth.provider] : []))
|
||||
|
||||
expect(providers.filter((provider) => ["azure", "azure-cognitive-services"].includes(provider))).toEqual(["azure"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Azure OpenAI resolves an ARM Resource ID to its resource name",
|
||||
"Azure uses the resource name stored by Entra login",
|
||||
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",
|
||||
)
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.azure].options.resourceName).toBe("entra-resource")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
azure: {
|
||||
options: {
|
||||
resourceID:
|
||||
"/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/test-rg/providers/Microsoft.CognitiveServices/accounts/test-resource",
|
||||
init: () =>
|
||||
setProcessEnv(
|
||||
"OPENCODE_AUTH_CONTENT",
|
||||
JSON.stringify({
|
||||
azure: {
|
||||
type: "oauth",
|
||||
access: "opencode-oauth-dummy-key",
|
||||
refresh: "opencode-oauth-dummy-key",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
accountId: "entra-resource",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
init: () => setProcessEnv("AZURE_API_KEY", "test-key"),
|
||||
}),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
it.instance("legacy Azure Cognitive Services API-key provider remains available", () =>
|
||||
it.instance("getSmallModel skips inferred models for Azure Cognitive Services", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "test-resource")
|
||||
yield* set("AZURE_COGNITIVE_SERVICES_API_KEY", "test-key")
|
||||
const providerID = ProviderV2.ID.make("azure-cognitive-services")
|
||||
const providers = yield* list
|
||||
const model = yield* Provider.use.getSmallModel(providerID)
|
||||
|
||||
expect(providers[providerID]).toBeDefined()
|
||||
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("azure-cognitive-services"))
|
||||
expect(model).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Azure resolves a Foundry project endpoint by model shape",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
const opus = yield* provider.getModel(ProviderV2.ID.azure, 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.azure, 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.azure, ModelV2.ID.make("gpt-5.1"))
|
||||
expect(languageURL(yield* provider.getLanguage(gpt), "/responses")).toBe(
|
||||
"https://oauth-resource.openai.azure.com/openai/v1/responses?api-version=v1",
|
||||
)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
azure: {
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
projectEndpoint: "https://oauth-resource.services.ai.azure.com/api/projects/oauth-project",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
init: () => setProcessEnv("AZURE_API_KEY", "test-key"),
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"getSmallModel respects config small_model override",
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -408,22 +408,20 @@ If tool calls aren't working well, pick a loaded model with strong tool-calling
|
||||
|
||||
---
|
||||
|
||||
### Microsoft Foundry and Azure OpenAI
|
||||
|
||||
Use the **Azure** provider for both Microsoft Foundry resources and standalone Azure OpenAI resources. A Foundry resource supports Azure OpenAI models as well as models from providers such as Anthropic, DeepSeek, Meta, and Mistral.
|
||||
### Azure OpenAI
|
||||
|
||||
:::note
|
||||
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. Create a resource.
|
||||
- For Microsoft Foundry, create a **Foundry resource** and project in the [Microsoft Foundry portal](https://ai.azure.com/). Copy the Project endpoint from the project overview. It looks like `https://RESOURCE.services.ai.azure.com/api/projects/PROJECT`.
|
||||
- For standalone Azure OpenAI, create an **Azure OpenAI** resource in the [Azure portal](https://portal.azure.com/) and copy its Resource name.
|
||||
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
|
||||
|
||||
2. Deploy a model to the resource.
|
||||
2. Go to [Azure AI Foundry](https://ai.azure.com/) and deploy a model.
|
||||
|
||||
:::note
|
||||
OpenCode matches exact deployment or Azure 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.
|
||||
The deployment name must match the model name for opencode to work properly.
|
||||
:::
|
||||
|
||||
3. Run the `/connect` command and search for **Azure**.
|
||||
@@ -432,91 +430,85 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
|
||||
/connect
|
||||
```
|
||||
|
||||
4. Choose an authentication method.
|
||||
- **API key**: Paste a key from the resource. To discover deployments with an API key, enter a Foundry Project endpoint in the next step.
|
||||
- **Microsoft Entra ID (Azure CLI)**: [Install the Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli), run `az login`, and assign the signed-in identity the required inference role. Use [`Cognitive Services OpenAI User`](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/role-based-access-control) for a standalone Azure OpenAI resource or [`Foundry User`](https://learn.microsoft.com/en-us/azure/foundry/concepts/rbac-foundry) for a Foundry resource. `Owner` or `Contributor` alone does not grant inference access.
|
||||
4. Enter your API key.
|
||||
|
||||
```txt
|
||||
┌ Select auth method
|
||||
┌ API key
|
||||
│
|
||||
│ API key
|
||||
│ Microsoft Entra ID (Azure CLI)
|
||||
└
|
||||
```
|
||||
|
||||
5. Finish the connection.
|
||||
- With **Microsoft Entra ID (Azure CLI)**, enter the Azure Resource name. OpenCode resolves it in the active Azure CLI subscription and discovers its deployments. You can paste a full Resource ID or Foundry Project endpoint instead when you need to bypass subscription-wide lookup.
|
||||
- With **API key**, choose the connection type, enter its value, then enter the key. A Foundry Project endpoint enables deployment discovery; a Resource name or Resource ID keeps the existing Azure OpenAI API-key behavior.
|
||||
|
||||
```txt
|
||||
┌ Enter Azure Resource name
|
||||
│
|
||||
│ my-resource
|
||||
└ enter
|
||||
```
|
||||
|
||||
API-key setup first asks which locator you want to use:
|
||||
|
||||
```txt
|
||||
┌ Select Azure connection type
|
||||
│
|
||||
│ Foundry Project endpoint
|
||||
│ Azure Resource name
|
||||
│ Azure Resource ID
|
||||
└
|
||||
```
|
||||
|
||||
If a different Azure CLI subscription is active, select the correct one and reconnect:
|
||||
5. Set your resource name as an environment variable:
|
||||
|
||||
```bash
|
||||
az account set --subscription NAME_OR_ID
|
||||
```
|
||||
|
||||
A full Resource ID or Foundry Project endpoint bypasses subscription-wide resource discovery.
|
||||
|
||||
6. Optional: set one connection value as an environment variable to skip the resource prompt during `/connect`.
|
||||
|
||||
```bash
|
||||
AZURE_RESOURCE_NAME=RESOURCE_NAME opencode
|
||||
AZURE_RESOURCE_NAME=XXX opencode
|
||||
```
|
||||
|
||||
Or add it to your bash profile:
|
||||
|
||||
```bash title="~/.bash_profile"
|
||||
export AZURE_RESOURCE_NAME=RESOURCE_NAME
|
||||
export AZURE_RESOURCE_NAME=XXX
|
||||
```
|
||||
|
||||
For a Foundry project, use its Project endpoint:
|
||||
|
||||
```bash
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://RESOURCE.services.ai.azure.com/api/projects/PROJECT opencode
|
||||
```
|
||||
|
||||
Or set the complete Resource ID:
|
||||
|
||||
```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 model. When deployment discovery is available, OpenCode shows only deployments it can match to the Azure model catalog. Resource-name or Resource-ID API-key configurations retain the existing full-catalog behavior.
|
||||
6. Run the `/models` command to select your deployed model.
|
||||
|
||||
```txt
|
||||
/models
|
||||
```
|
||||
|
||||
#### Microsoft Entra ID (Azure CLI)
|
||||
|
||||
You can use your Azure CLI session instead of an API key. [Install the Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli), run `az login`, then select **Microsoft Entra ID (Azure CLI)** when connecting the **Azure** provider and enter the same Resource name.
|
||||
|
||||
Assign your identity the inference role required by the deployment: **Cognitive Services OpenAI User** for Azure OpenAI models or **Cognitive Services User** for other Foundry models. OpenCode refreshes access tokens through the Azure CLI, so you only need to sign in again when the CLI session expires.
|
||||
|
||||
---
|
||||
|
||||
### Azure Cognitive Services
|
||||
|
||||
The separate `azure-cognitive-services` provider remains available for existing API-key configurations. New Microsoft Foundry connections should use the **Azure** provider above; both providers can address the same underlying Foundry 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://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/`)
|
||||
- **API key**: Either `KEY 1` or `KEY 2` from your resource
|
||||
|
||||
Existing environment configuration remains supported:
|
||||
2. Go to [Azure AI Foundry](https://ai.azure.com/) and deploy a model.
|
||||
|
||||
```bash
|
||||
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=RESOURCE_NAME \
|
||||
AZURE_COGNITIVE_SERVICES_API_KEY=API_KEY \
|
||||
opencode
|
||||
```
|
||||
:::note
|
||||
The deployment name must match the model name for opencode to work properly.
|
||||
:::
|
||||
|
||||
3. Run the `/connect` command and search for **Azure Cognitive Services**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
4. Enter your API key.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
│
|
||||
│
|
||||
└ enter
|
||||
```
|
||||
|
||||
5. Set your resource name as an environment variable:
|
||||
|
||||
```bash
|
||||
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX opencode
|
||||
```
|
||||
|
||||
Or add it to your bash profile:
|
||||
|
||||
```bash title="~/.bash_profile"
|
||||
export AZURE_COGNITIVE_SERVICES_RESOURCE_NAME=XXX
|
||||
```
|
||||
|
||||
6. Run the `/models` command to select your deployed model.
|
||||
|
||||
```txt
|
||||
/models
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user