mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 07:36:07 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fdb104bb5 | ||
|
|
b02419621b | ||
|
|
95f098a721 | ||
|
|
8c39e13b77 | ||
|
|
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,6 +1,70 @@
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Provider } from "@opencode-ai/sdk/v2"
|
||||
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)
|
||||
|
||||
const decodeAzureAccounts = Schema.decodeUnknownPromise(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.NonEmptyString,
|
||||
resourceGroup: Schema.NonEmptyString,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const decodeAzureDeployments = Schema.decodeUnknownPromise(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.NonEmptyString,
|
||||
properties: Schema.Struct({
|
||||
model: Schema.Struct({
|
||||
name: Schema.NonEmptyString,
|
||||
}),
|
||||
provisioningState: Schema.NonEmptyString,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
type AzureCommand = {
|
||||
quiet(): AzureCommand
|
||||
json(): Promise<unknown>
|
||||
}
|
||||
|
||||
type AzureShell = (strings: TemplateStringsArray, ...values: 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
|
||||
}
|
||||
|
||||
export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
const prompts = []
|
||||
if (!process.env.AZURE_RESOURCE_NAME) {
|
||||
prompts.push({
|
||||
@@ -12,15 +76,102 @@ export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
}
|
||||
|
||||
return {
|
||||
provider: {
|
||||
id: "azure",
|
||||
async models(provider, context) {
|
||||
if (context.auth?.type !== "oauth") return provider.models
|
||||
if (!context.auth.accountId) return {}
|
||||
return discoverAzureModels(provider.models, context.auth.accountId, shell).catch(() => ({}))
|
||||
},
|
||||
},
|
||||
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,
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverAzureModels(models: Provider["models"], resourceName: string, shell: AzureShell) {
|
||||
const accounts = await decodeAzureAccounts(
|
||||
await shell`az cognitiveservices account list --output json --only-show-errors`.quiet().json(),
|
||||
)
|
||||
const account = accounts.find((account) => account.name.toLowerCase() === resourceName.toLowerCase())
|
||||
if (!account) return {}
|
||||
|
||||
const deployments = await decodeAzureDeployments(
|
||||
await shell`az cognitiveservices account deployment list --name ${account.name} --resource-group ${account.resourceGroup} --output json --only-show-errors`
|
||||
.quiet()
|
||||
.json(),
|
||||
)
|
||||
const found = new Map<string, Provider["models"][string]>()
|
||||
deployments.forEach((deployment) => {
|
||||
if (deployment.properties.provisioningState !== "Succeeded") return
|
||||
const modelID = Object.keys(models).find(
|
||||
(modelID) => modelID.toLowerCase() === deployment.properties.model.name.toLowerCase(),
|
||||
)
|
||||
if (!modelID) return
|
||||
found.set(modelID, {
|
||||
...models[modelID],
|
||||
api: {
|
||||
...models[modelID].api,
|
||||
id: deployment.name,
|
||||
},
|
||||
})
|
||||
})
|
||||
return Object.fromEntries(found)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -244,6 +244,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return [
|
||||
provider.options?.resourceName,
|
||||
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
|
||||
auth?.type === "oauth" ? auth.accountId : undefined,
|
||||
env["AZURE_RESOURCE_NAME"],
|
||||
].find((name) => typeof name === "string" && name.trim() !== "")
|
||||
})
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
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 { OAUTH_DUMMY_KEY } from "../../src/auth"
|
||||
import { createAzureAuthHooks } from "../../src/plugin/azure"
|
||||
|
||||
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",
|
||||
name: "Azure",
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: {},
|
||||
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 (typeof result !== "function") throw new Error("Azure custom fetch is missing")
|
||||
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
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 models(...ids: string[]): Provider["models"] {
|
||||
return Object.fromEntries(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
providerID: "azure",
|
||||
name: id,
|
||||
family: "",
|
||||
api: { id, url: "", npm: "@ai-sdk/azure" },
|
||||
status: "active",
|
||||
headers: {},
|
||||
options: {},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 0, output: 0 },
|
||||
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,
|
||||
},
|
||||
release_date: "",
|
||||
variants: {},
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function azureShell(scopes: string[]) {
|
||||
return (_strings: TemplateStringsArray, ...values: string[]) => {
|
||||
const output = {
|
||||
quiet: () => output,
|
||||
json: async () => {
|
||||
const scope = values[0]
|
||||
scopes.push(scope)
|
||||
return {
|
||||
accessToken: `${scope}-token`,
|
||||
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
}
|
||||
},
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
function discoveryShell(accounts: unknown, deployments: unknown, commands: string[]) {
|
||||
return (strings: TemplateStringsArray, ...values: string[]) => {
|
||||
const command = String.raw(strings, ...values)
|
||||
commands.push(command)
|
||||
const output = {
|
||||
quiet: () => output,
|
||||
json: async () => (command.includes("deployment list") ? deployments : accounts),
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
describe("plugin.azure", () => {
|
||||
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?.id).toBe("azure")
|
||||
expect(hooks.auth?.methods.map((method) => [method.type, method.label])).toEqual([
|
||||
["api", "API key"],
|
||||
["oauth", "Microsoft Entra ID (Azure CLI)"],
|
||||
])
|
||||
expect(hooks.auth?.methods[0]).toEqual({
|
||||
type: "api",
|
||||
label: "API key",
|
||||
prompts: [
|
||||
{
|
||||
type: "text",
|
||||
key: "resourceName",
|
||||
message: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(hooks.auth?.methods[1].prompts).toEqual(hooks.auth?.methods[0].prompts)
|
||||
})
|
||||
|
||||
test("checks Azure CLI and stores the resource name", async () => {
|
||||
const scopes: string[] = []
|
||||
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")
|
||||
|
||||
expect(await authorization.callback()).toMatchObject({
|
||||
type: "success",
|
||||
access: OAUTH_DUMMY_KEY,
|
||||
refresh: OAUTH_DUMMY_KEY,
|
||||
accountId: "test-resource",
|
||||
})
|
||||
expect(scopes).toEqual(["https://cognitiveservices.azure.com/.default"])
|
||||
})
|
||||
|
||||
test("discovers deployed models through Azure CLI", async () => {
|
||||
const commands: string[] = []
|
||||
const hooks = createAzureAuthHooks(
|
||||
discoveryShell(
|
||||
[{ name: "test-resource", resourceGroup: "test-group" }],
|
||||
[
|
||||
{
|
||||
name: "gpt-production",
|
||||
properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "DeepSeek-V4-Flash",
|
||||
properties: { model: { name: "DeepSeek-V4-Flash" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "phi-production",
|
||||
properties: { model: { name: "Phi-4-mini-instruct" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "gpt-5-nano",
|
||||
properties: { model: { name: "gpt-5-nano" }, provisioningState: "Creating" },
|
||||
},
|
||||
],
|
||||
commands,
|
||||
),
|
||||
)
|
||||
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", "deepseek-v4-flash", "phi-4-mini", "phi-4-mini-instruct", "gpt-5-nano"),
|
||||
},
|
||||
{ auth: oauth },
|
||||
)
|
||||
|
||||
expect(Object.keys(result)).toEqual(["gpt-5-mini", "deepseek-v4-flash", "phi-4-mini-instruct"])
|
||||
expect(result["gpt-5-mini"].api.id).toBe("gpt-production")
|
||||
expect(result["deepseek-v4-flash"].api.id).toBe("DeepSeek-V4-Flash")
|
||||
expect(result["phi-4-mini-instruct"].api.id).toBe("phi-production")
|
||||
expect(commands).toEqual([
|
||||
"az cognitiveservices account list --output json --only-show-errors",
|
||||
"az cognitiveservices account deployment list --name test-resource --resource-group test-group --output json --only-show-errors",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps startup running when Azure discovery fails", async () => {
|
||||
const hooks = createAzureAuthHooks(() => {
|
||||
const output = {
|
||||
quiet: () => output,
|
||||
json: async () => {
|
||||
throw new Error("Azure CLI failed")
|
||||
},
|
||||
}
|
||||
return output
|
||||
})
|
||||
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 })).toEqual({})
|
||||
})
|
||||
|
||||
test("does not change API-key loading", async () => {
|
||||
const scopes: string[] = []
|
||||
const hooks = createAzureAuthHooks(azureShell(scopes))
|
||||
const catalog = models("gpt-5-mini")
|
||||
const list = hooks.provider?.models
|
||||
if (!list) throw new Error("Azure provider model hook is missing")
|
||||
|
||||
expect(await loader(hooks)(async () => ({ type: "api", key: "test-key" }), provider)).toEqual({})
|
||||
expect(await list({ ...provider, models: catalog }, { auth: { type: "api", key: "test-key" } })).toBe(catalog)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -457,6 +457,14 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
|
||||
/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.
|
||||
|
||||
OpenCode finds the Resource group and discovers its deployed models from the active Azure CLI subscription. Run `az account set --subscription NAME_OR_ID` first if the Resource is in a different subscription.
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user