mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 15:16:22 +00:00
Compare commits
32
Commits
dev
...
azure-cli-auth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed11c895c6 | ||
|
|
8bc87d3c6a | ||
|
|
1ec78c705a | ||
|
|
ede6c4f1a6 | ||
|
|
c34171845e | ||
|
|
4a7a497597 | ||
|
|
b7f72b1bd4 | ||
|
|
cff8ba313a | ||
|
|
836fcfb742 | ||
|
|
03d1099933 | ||
|
|
e41e416beb | ||
|
|
2d16759723 | ||
|
|
a41ed1cdf2 | ||
|
|
24479e2df9 | ||
|
|
365a95c91b | ||
|
|
49ca92621d | ||
|
|
92aa12a609 | ||
|
|
484b662665 | ||
|
|
e8d955a40c | ||
|
|
7adfe50b9a | ||
|
|
5613ebb514 | ||
|
|
c0ecd5da0e | ||
|
|
be5f5dbc29 | ||
|
|
3cf9cfb6f3 | ||
|
|
fcc21f3dc5 | ||
|
|
fa40659b7f | ||
|
|
fdf14a8462 | ||
|
|
db43ee0b05 | ||
|
|
2b53cf0f4a | ||
|
|
dffa1045d3 | ||
|
|
d862d02d0e | ||
|
|
4abac2a431 |
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user