mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-19 03:37:02 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20b0075b70 | |||
| de060b5b85 | |||
| 478e12d2dc | |||
| 526d6ee16b | |||
| 4d783412e2 | |||
| 40894101d9 |
@@ -1,4 +1,5 @@
|
||||
import { Clock, Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import { Clock, Effect, FiberHandle, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
@@ -7,14 +8,20 @@ import { App } from "../../app.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { iife } from "../../util/iife.js"
|
||||
import { which } from "../../util/which.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
import { configuredSettings } from "./configured.js"
|
||||
|
||||
const cognitiveScope = "https://cognitiveservices.azure.com/.default"
|
||||
const foundryScope = "https://ai.azure.com/.default"
|
||||
const managementScope = "https://management.azure.com/.default"
|
||||
const methodID = Integration.MethodID.make("azure-cli")
|
||||
// A resource name becomes a hostname label and a query literal, so anything else never leaves the process.
|
||||
const resourcePattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/
|
||||
const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
|
||||
const decodeToken = Schema.decodeUnknownEffect(
|
||||
Schema.Struct({
|
||||
@@ -23,209 +30,437 @@ const decodeToken = Schema.decodeUnknownEffect(
|
||||
expiresOn: Schema.optional(Schema.NonEmptyString),
|
||||
}),
|
||||
)
|
||||
export const AzurePlugin = define({
|
||||
id: "opencode.provider.azure",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(Provider.ID.azure)
|
||||
const processes = yield* AppProcess.Service
|
||||
const bus = yield* Bus.Service
|
||||
const tokens = new Map<string, { access: string; expires: number }>()
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
const loaded: { resource?: string } = {}
|
||||
const ResourceDeployments = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
|
||||
const decodeResourceDeployment = Schema.decodeUnknownOption(
|
||||
Schema.Struct({ id: Schema.NonEmptyString, model: Schema.NonEmptyString, status: Schema.String }),
|
||||
)
|
||||
const ManagementDeployments = Schema.Struct({
|
||||
value: Schema.Array(Schema.Unknown),
|
||||
nextLink: Schema.optional(Schema.NonEmptyString),
|
||||
})
|
||||
const decodeManagementDeployment = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
name: Schema.NonEmptyString,
|
||||
properties: Schema.Struct({
|
||||
model: Schema.Struct({ name: Schema.NonEmptyString }),
|
||||
provisioningState: Schema.String,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const ResourceQuery = Schema.Struct({ query: Schema.String })
|
||||
const Resources = Schema.Struct({ data: Schema.Array(Schema.Struct({ id: Schema.NonEmptyString })) })
|
||||
|
||||
const command = (args: string[]) =>
|
||||
processes
|
||||
.run(ChildProcess.make("az", args, { extendEnv: true, stdin: "ignore" }), { timeout: "10 seconds" })
|
||||
.pipe(
|
||||
Effect.flatMap(AppProcess.requireSuccess),
|
||||
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
|
||||
type Deployment = { readonly name: string; readonly model: string }
|
||||
|
||||
export function make(
|
||||
endpoints = {
|
||||
resource: (name: string) => `https://${name}.openai.azure.com/openai`,
|
||||
management: "https://management.azure.com",
|
||||
},
|
||||
) {
|
||||
return define({
|
||||
id: "opencode.provider.azure",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(Provider.ID.azure)
|
||||
const processes = yield* AppProcess.Service
|
||||
const bus = yield* Bus.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const providers = yield* Provider.Service
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const tokens = new Map<string, { access: string; expires: number }>()
|
||||
// A resource keeps its Azure Resource Manager ID until it is deleted, so discovery looks it up once.
|
||||
const resourceIDs = new Map<string, string>()
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
const discovery = yield* FiberHandle.make<void, never>()
|
||||
const loaded: {
|
||||
resource?: string
|
||||
url?: string
|
||||
deployments?: readonly Deployment[]
|
||||
connection?: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
||||
} = {}
|
||||
|
||||
const command = (args: string[]) =>
|
||||
processes
|
||||
.run(ChildProcess.make("az", args, { extendEnv: true, stdin: "ignore" }), { timeout: "10 seconds" })
|
||||
.pipe(
|
||||
Effect.flatMap(AppProcess.requireSuccess),
|
||||
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
|
||||
)
|
||||
|
||||
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const cached = tokens.get(scope)
|
||||
if (cached && cached.expires - now > 5 * 60_000) return cached
|
||||
const result = yield* command(["account", "get-access-token", "--scope", scope, "--output", "json"]).pipe(
|
||||
Effect.flatMap(decodeToken),
|
||||
)
|
||||
|
||||
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const cached = tokens.get(scope)
|
||||
if (cached && cached.expires - now > 5 * 60_000) return cached
|
||||
const result = yield* command(["account", "get-access-token", "--scope", scope, "--output", "json"]).pipe(
|
||||
Effect.flatMap(decodeToken),
|
||||
)
|
||||
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
|
||||
if (!Number.isFinite(expires))
|
||||
return yield* Effect.fail(new Error("Azure CLI returned an invalid token expiration"))
|
||||
const refreshed = { access: result.accessToken, expires }
|
||||
tokens.set(scope, refreshed)
|
||||
return refreshed
|
||||
})
|
||||
|
||||
const available = Boolean(which("az"))
|
||||
const form = () =>
|
||||
iife(() => {
|
||||
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
|
||||
return Form.Fields.make([
|
||||
{
|
||||
type: "string",
|
||||
key: "resourceName",
|
||||
title: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
|
||||
if (!Number.isFinite(expires))
|
||||
return yield* Effect.fail(new Error("Azure CLI returned an invalid token expiration"))
|
||||
const refreshed = { access: result.accessToken, expires }
|
||||
tokens.set(scope, refreshed)
|
||||
return refreshed
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.method.update({
|
||||
integrationID: Provider.ID.azure,
|
||||
method: { type: "key", label: "API key", form: form() },
|
||||
})
|
||||
if (!available) return
|
||||
editor.method.update({
|
||||
integrationID: Provider.ID.azure,
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Microsoft Entra ID (Azure CLI)",
|
||||
form: form(),
|
||||
},
|
||||
authorize: (answer) =>
|
||||
Effect.succeed({
|
||||
mode: "auto" as const,
|
||||
url: "",
|
||||
instructions: "Sign in with `az login` before continuing.",
|
||||
callback: Effect.gen(function* () {
|
||||
const resourceName =
|
||||
typeof answer.resourceName === "string" ? answer.resourceName : resolveResourceName(configured)
|
||||
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
|
||||
const current = yield* token(cognitiveScope)
|
||||
loaded.resource = resourceName
|
||||
yield* ctx.provider.reload()
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: current.access,
|
||||
refresh: "azure-cli",
|
||||
expires: current.expires,
|
||||
metadata: { resourceName },
|
||||
})
|
||||
}),
|
||||
}),
|
||||
refresh: (credential) =>
|
||||
token(cognitiveScope).pipe(
|
||||
Effect.map((current) =>
|
||||
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
|
||||
const management = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
token(managementScope).pipe(
|
||||
Effect.flatMap((current) =>
|
||||
http.execute(
|
||||
request.pipe(
|
||||
HttpClientRequest.bearerToken(current.access),
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const load = Effect.fn("AzurePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID) {
|
||||
loaded.resource = undefined
|
||||
return
|
||||
}
|
||||
const resource =
|
||||
typeof credential.metadata?.resourceName === "string" ? credential.metadata.resourceName : undefined
|
||||
loaded.resource = resource
|
||||
})
|
||||
|
||||
yield* load()
|
||||
yield* ctx.provider.transform((evt) => {
|
||||
for (const item of evt.list()) {
|
||||
if (
|
||||
item.provider.id !== Provider.ID.azure &&
|
||||
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
|
||||
)
|
||||
continue
|
||||
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
|
||||
const websocket = responsesWebSocketCapable(item.provider)
|
||||
if (!resourceName && !websocket) continue
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...(resourceName === undefined ? {} : { resourceName }),
|
||||
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
|
||||
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
|
||||
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
|
||||
: {}),
|
||||
}
|
||||
|
||||
const available = Boolean(which("az"))
|
||||
const form = () =>
|
||||
iife(() => {
|
||||
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
|
||||
return Form.Fields.make([
|
||||
{
|
||||
type: "string",
|
||||
key: "resourceName",
|
||||
title: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const item of models.provider.list()) {
|
||||
if (
|
||||
item.provider.id !== Provider.ID.azure &&
|
||||
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.method.update({
|
||||
integrationID: Provider.ID.azure,
|
||||
method: { type: "key", label: "API key", form: form() },
|
||||
})
|
||||
if (!available) return
|
||||
editor.method.update({
|
||||
integrationID: Provider.ID.azure,
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Microsoft Entra ID (Azure CLI)",
|
||||
form: form(),
|
||||
},
|
||||
authorize: (answer) =>
|
||||
Effect.succeed({
|
||||
mode: "auto" as const,
|
||||
url: "",
|
||||
instructions: "Sign in with `az login` before continuing.",
|
||||
callback: Effect.gen(function* () {
|
||||
const resourceName =
|
||||
(typeof answer.resourceName === "string" ? answer.resourceName.trim() : "") ||
|
||||
resolveResourceName(configured)
|
||||
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
|
||||
const current = yield* token(cognitiveScope)
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: current.access,
|
||||
refresh: "azure-cli",
|
||||
expires: current.expires,
|
||||
metadata: { resourceName },
|
||||
})
|
||||
}),
|
||||
}),
|
||||
refresh: (credential) =>
|
||||
token(cognitiveScope).pipe(
|
||||
Effect.map((current) =>
|
||||
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const load = Effect.fn("AzurePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
|
||||
// Startup awaits this, so it reads the stored credential: resolving the connection would refresh an
|
||||
// expired token through the Azure CLI.
|
||||
const stored =
|
||||
connection?.type === "credential" ? yield* credentials.get(Credential.ID.make(connection.id)) : undefined
|
||||
return { connection, resource: credentialResource(stored?.value) }
|
||||
})
|
||||
|
||||
// Resource Graph searches every subscription the Azure CLI account can read, not only the selected one.
|
||||
const findResource = Effect.fn("AzurePlugin.findResource")(function* (resource: string) {
|
||||
const response = yield* HttpClientRequest.post(
|
||||
`${endpoints.management}/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01`,
|
||||
).pipe(
|
||||
HttpClientRequest.schemaBodyJson(ResourceQuery)({ query: resourceQuery(resource) }),
|
||||
Effect.flatMap(management),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Resources)),
|
||||
Effect.timeout("10 seconds"),
|
||||
)
|
||||
continue
|
||||
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
|
||||
for (const model of models.list(item.provider.id)) {
|
||||
models.update(item.provider.id, model.id, (draft) => {
|
||||
if (resourceName && typeof draft.settings?.baseURL === "string")
|
||||
draft.settings.baseURL = expandResourceName(
|
||||
draft.settings.baseURL,
|
||||
resolveResourceName(draft.settings, resourceName) ?? resourceName,
|
||||
)
|
||||
const id = response.data[0]?.id
|
||||
if (!id) return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found`))
|
||||
return id
|
||||
})
|
||||
|
||||
const managementDeployments = Effect.fn("AzurePlugin.managementDeployments")(function* (resource: string) {
|
||||
const key = resource.toLowerCase()
|
||||
const id = resourceIDs.get(key) ?? (yield* findResource(resource))
|
||||
resourceIDs.set(key, id)
|
||||
const origin = new URL(endpoints.management).origin
|
||||
return yield* Stream.paginate(`${endpoints.management}${id}/deployments?api-version=2024-10-01`, (url) =>
|
||||
management(HttpClientRequest.get(url)).pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(ManagementDeployments)),
|
||||
Effect.timeout("10 seconds"),
|
||||
Effect.flatMap((response) =>
|
||||
// Every page carries the management token, so a page link must stay on the management endpoint.
|
||||
response.nextLink !== undefined && URL.parse(response.nextLink)?.origin !== origin
|
||||
? Effect.fail(new Error("Azure returned a deployment page outside the management endpoint"))
|
||||
: Effect.succeed([
|
||||
response.value.flatMap((raw): Deployment[] => {
|
||||
const item = Option.getOrUndefined(decodeManagementDeployment(raw))
|
||||
return item?.properties.provisioningState === "Succeeded"
|
||||
? [{ name: item.name, model: item.properties.model.name }]
|
||||
: []
|
||||
}),
|
||||
Option.fromNullishOr(response.nextLink),
|
||||
] as const),
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
Stream.runCollect,
|
||||
// A moved or recreated resource has a new ID, so the next discovery looks it up again.
|
||||
Effect.tapError(() => Effect.sync(() => resourceIDs.delete(key))),
|
||||
)
|
||||
})
|
||||
|
||||
const resourceDeployments = Effect.fn("AzurePlugin.resourceDeployments")(function* (
|
||||
url: string,
|
||||
credential: Credential.Value,
|
||||
) {
|
||||
return yield* http
|
||||
.execute(
|
||||
HttpClientRequest.get(url).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)),
|
||||
credential.type === "key"
|
||||
? HttpClientRequest.setHeader("api-key", credential.key)
|
||||
: HttpClientRequest.bearerToken(credential.access),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(ResourceDeployments)),
|
||||
Effect.timeout("10 seconds"),
|
||||
Effect.map((response) =>
|
||||
response.data.flatMap((raw): Deployment[] => {
|
||||
const item = Option.getOrUndefined(decodeResourceDeployment(raw))
|
||||
return item?.status === "succeeded" ? [{ name: item.id, model: item.model }] : []
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
// Azure documents the management API as the deployment inventory, but only an Azure CLI session can reach it.
|
||||
// The resource's legacy inventory serves API keys and identities without Azure Resource Manager read access.
|
||||
const deployments = (url: string, resource: string, credential: Credential.Value) =>
|
||||
credential.type === "oauth"
|
||||
? managementDeployments(resource).pipe(
|
||||
Effect.catch(() => resourceDeployments(url, credential)),
|
||||
)
|
||||
: resourceDeployments(url, credential)
|
||||
|
||||
// Local and quick, so a switch rebinds the provider before discovery for the new connection calls Azure.
|
||||
const rebind = () =>
|
||||
loading.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = yield* load()
|
||||
if (
|
||||
IntegrationConnection.key(current.connection) === IntegrationConnection.key(loaded.connection) &&
|
||||
current.resource === loaded.resource
|
||||
)
|
||||
return
|
||||
Object.assign(loaded, current, { url: undefined, deployments: undefined })
|
||||
yield* ctx.provider.reload()
|
||||
}),
|
||||
)
|
||||
|
||||
const discover = Effect.fn("AzurePlugin.discover")(function* () {
|
||||
const connection = loaded.connection
|
||||
const settings = (yield* providers.get(Provider.ID.azure))?.settings
|
||||
const name = loaded.resource ?? resolveResourceName(settings)
|
||||
// A custom endpoint may expose other deployments than the resource does, so it keeps the catalog.
|
||||
const url =
|
||||
connection && name !== undefined && resourcePattern.test(name) && typeof settings?.baseURL !== "string"
|
||||
? `${endpoints.resource(name)}/deployments?api-version=2022-12-01`
|
||||
: undefined
|
||||
if (loaded.connection !== connection) return
|
||||
// Keep the last inventory through transient failures only for the same connection and resource.
|
||||
if (loaded.url !== url) {
|
||||
loaded.url = url
|
||||
if (loaded.deployments) {
|
||||
loaded.deployments = undefined
|
||||
yield* ctx.model.reload()
|
||||
}
|
||||
}
|
||||
if (!connection || !name || !url) return
|
||||
const credential = yield* ctx.integration.connection
|
||||
.resolve(connection)
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!credential || (credential.type === "oauth" && credential.methodID !== methodID)) return
|
||||
const found = yield* deployments(url, name, credential).pipe(
|
||||
// Azure promises no order; normalize it so a reordered response does not rebuild the model list.
|
||||
Effect.map((list) => list.toSorted((a, b) => a.name.localeCompare(b.name))),
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to sync Azure deployments", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (!found) return
|
||||
if (
|
||||
loaded.connection !== connection ||
|
||||
IntegrationConnection.key(connection) !==
|
||||
IntegrationConnection.key(yield* ctx.integration.connection.active(Provider.ID.azure))
|
||||
)
|
||||
return
|
||||
if (JSON.stringify(found) === JSON.stringify(loaded.deployments)) return
|
||||
const catalog = new Set(
|
||||
Array.from((yield* providers.snapshot()).records.get(Provider.ID.azure)?.models.keys() ?? [], (id) =>
|
||||
id.toLowerCase(),
|
||||
),
|
||||
)
|
||||
const unmatched = found.filter((deployment) => !catalog.has(deployment.model.toLowerCase()))
|
||||
if (unmatched.length > 0)
|
||||
yield* Effect.logWarning("Azure deployments of models outside the catalog need explicit configuration", {
|
||||
deployments: unmatched.map((deployment) => deployment.name),
|
||||
})
|
||||
loaded.deployments = found
|
||||
yield* ctx.model.reload()
|
||||
})
|
||||
|
||||
const refresh = () => rebind().pipe(Effect.andThen(FiberHandle.run(discovery, discover())))
|
||||
|
||||
// The connection's resource wins for Azure itself, matching the runtime merge of credentials over settings.
|
||||
const resourceFor = (provider: Provider.Info) =>
|
||||
provider.id === Provider.ID.azure
|
||||
? (loaded.resource ?? resolveResourceName(provider.settings))
|
||||
: resolveResourceName(provider.settings, loaded.resource)
|
||||
|
||||
Object.assign(loaded, yield* load())
|
||||
yield* ctx.provider.transform((evt) => {
|
||||
for (const item of evt.list()) {
|
||||
if (
|
||||
item.provider.id !== Provider.ID.azure &&
|
||||
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
|
||||
)
|
||||
continue
|
||||
const resourceName = resourceFor(item.provider)
|
||||
const websocket = responsesWebSocketCapable(item.provider)
|
||||
if (!resourceName && !websocket) continue
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...(resourceName === undefined ? {} : { resourceName }),
|
||||
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
|
||||
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
|
||||
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
const item = evt.get(Provider.ID.azure)
|
||||
if (!item) return
|
||||
// Bind resource settings and discovery to their account, so a switch hides them until the rebind.
|
||||
// Keep the full templates here for explicit configuration; the model transform narrows the visible list.
|
||||
evt.add({
|
||||
info: item.provider,
|
||||
models: Array.from(item.models.values()),
|
||||
sourceConnection: loaded.connection,
|
||||
})
|
||||
})
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const item of models.provider.list()) {
|
||||
if (
|
||||
item.provider.id !== Provider.ID.azure &&
|
||||
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
|
||||
)
|
||||
continue
|
||||
const resourceName = resourceFor(item.provider)
|
||||
for (const model of models.list(item.provider.id)) {
|
||||
models.update(item.provider.id, model.id, (draft) => {
|
||||
if (resourceName && typeof draft.settings?.baseURL === "string")
|
||||
draft.settings.baseURL = expandResourceName(
|
||||
draft.settings.baseURL,
|
||||
resolveResourceName(draft.settings, resourceName) ?? resourceName,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!loaded.deployments) return
|
||||
// Narrowing here rather than in the provider catalog keeps every catalog model available as the base
|
||||
// of a model the user configures explicitly; those are applied after this transform.
|
||||
const catalog = models.list(Provider.ID.azure)
|
||||
const deployed = deployedModels(loaded.deployments, catalog)
|
||||
for (const model of catalog) {
|
||||
if (!deployed.has(model.id)) models.remove(Provider.ID.azure, model.id)
|
||||
}
|
||||
for (const [id, model] of deployed) {
|
||||
models.update(Provider.ID.azure, id, (draft) => Object.assign(draft, model))
|
||||
}
|
||||
})
|
||||
|
||||
const reload = () => loading.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("azure")),
|
||||
Stream.runForEach(reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
// A switch interrupts discovery for the previous connection instead of waiting for its Azure calls.
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("azure")),
|
||||
Stream.runForEach(() => refresh()),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
// Deployments load in the background so startup never waits on Azure; the catalog serves until they arrive.
|
||||
// Later changes load when the connection changes, so a new deployment needs a reconnect or restart.
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
|
||||
// Entra bearer tokens are minted per request from the target URL's scope, so they are injected
|
||||
// at the transport hooks rather than stored as a credential.
|
||||
const bearer = Effect.fn("AzurePlugin.bearer")(function* (url: string) {
|
||||
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
|
||||
const target = new URL(url)
|
||||
const scope =
|
||||
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
|
||||
? foundryScope
|
||||
: cognitiveScope
|
||||
const current = yield* token(scope).pipe(Effect.orDie)
|
||||
return `Bearer ${current.access}`
|
||||
})
|
||||
yield* ctx.session.hook(
|
||||
"http.request",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.azure) return
|
||||
const authorization = yield* bearer(evt.request.url)
|
||||
if (!authorization) return
|
||||
evt.request.headers.delete("api-key")
|
||||
evt.request.headers.delete("x-api-key")
|
||||
evt.request.headers.set("authorization", authorization)
|
||||
evt.request.headers.set("user-agent", App.useragent(ctx.app))
|
||||
}),
|
||||
{ providerID: Provider.ID.azure },
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"experimental.ws.handshake",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.azure) return
|
||||
const authorization = yield* bearer(evt.url)
|
||||
if (!authorization) return
|
||||
delete evt.headers["api-key"]
|
||||
delete evt.headers["x-api-key"]
|
||||
evt.headers.authorization = authorization
|
||||
evt.headers["user-agent"] = App.useragent(ctx.app)
|
||||
}),
|
||||
{ providerID: Provider.ID.azure },
|
||||
)
|
||||
}),
|
||||
})
|
||||
// Entra bearer tokens are minted per request from the target URL's scope, so they are injected
|
||||
// at the transport hooks rather than stored as a credential.
|
||||
const bearer = Effect.fn("AzurePlugin.bearer")(function* (url: string) {
|
||||
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
|
||||
const target = new URL(url)
|
||||
const scope =
|
||||
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
|
||||
? foundryScope
|
||||
: cognitiveScope
|
||||
const current = yield* token(scope).pipe(Effect.orDie)
|
||||
return `Bearer ${current.access}`
|
||||
})
|
||||
yield* ctx.session.hook(
|
||||
"http.request",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.azure) return
|
||||
const authorization = yield* bearer(evt.request.url)
|
||||
if (!authorization) return
|
||||
evt.request.headers.delete("api-key")
|
||||
evt.request.headers.delete("x-api-key")
|
||||
evt.request.headers.set("authorization", authorization)
|
||||
evt.request.headers.set("user-agent", App.useragent(ctx.app))
|
||||
}),
|
||||
{ providerID: Provider.ID.azure },
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"experimental.ws.handshake",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.azure) return
|
||||
const authorization = yield* bearer(evt.url)
|
||||
if (!authorization) return
|
||||
delete evt.headers["api-key"]
|
||||
delete evt.headers["x-api-key"]
|
||||
evt.headers.authorization = authorization
|
||||
evt.headers["user-agent"] = App.useragent(ctx.app)
|
||||
}),
|
||||
{ providerID: Provider.ID.azure },
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
}
|
||||
|
||||
export const AzurePlugin = make()
|
||||
|
||||
function resolveResourceName(settings: Readonly<Record<string, unknown>> | undefined, fallback?: string) {
|
||||
const configured = settings?.resourceName
|
||||
@@ -239,6 +474,49 @@ function expandResourceName(baseURL: string, resourceName: string) {
|
||||
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
|
||||
}
|
||||
|
||||
// The Azure CLI method stores the resource as credential metadata, the API key method as its form answer.
|
||||
function credentialResource(credential: Credential.Value | undefined) {
|
||||
const resource =
|
||||
credential?.type === "key"
|
||||
? credential.configuration?.resourceName
|
||||
: credential?.methodID === methodID
|
||||
? credential.metadata?.resourceName
|
||||
: undefined
|
||||
return typeof resource === "string" && resource.trim() !== "" ? resource : undefined
|
||||
}
|
||||
|
||||
function resourceQuery(resource: string) {
|
||||
return [
|
||||
"resources",
|
||||
"| where type =~ 'microsoft.cognitiveservices/accounts' and kind in~ ('AIServices', 'OpenAI')",
|
||||
// The custom subdomain is the resource name of every endpoint, and Entra ID authentication requires one.
|
||||
// Subdomains are globally unique, so a name matches at most one resource.
|
||||
"| extend resourceName = tostring(properties.customSubDomainName)",
|
||||
`| where resourceName =~ '${resource}'`,
|
||||
"| project id",
|
||||
"| take 1",
|
||||
].join(" ")
|
||||
}
|
||||
|
||||
// Azure addresses a model by its deployment name, while limits, costs, and routes belong to the catalog model it
|
||||
// deploys. A deployment of a model the catalog does not know has none of those and is left to explicit configuration.
|
||||
function deployedModels(deployments: readonly Deployment[], catalog: readonly Model.MutableInfo[]) {
|
||||
const models = new Map(catalog.map((model) => [model.id.toLowerCase(), model]))
|
||||
return deployments.reduce((result, deployment) => {
|
||||
const model = models.get(deployment.model.toLowerCase())
|
||||
if (!model) return result
|
||||
// A deployment's ID must not depend on which other deployments happen to exist.
|
||||
const id = deployment.name.toLowerCase() === model.id.toLowerCase() ? model.id : Model.ID.make(deployment.name)
|
||||
if (result.has(id)) return result
|
||||
return result.set(id, {
|
||||
...structuredClone(model),
|
||||
id,
|
||||
modelID: Model.ID.make(deployment.name),
|
||||
name: id === model.id ? model.name : `${model.name} (${deployment.name})`,
|
||||
})
|
||||
}, new Map<Model.ID, Model.MutableInfo>())
|
||||
}
|
||||
|
||||
function responsesWebSocketCapable(provider: Provider.Info) {
|
||||
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
|
||||
const settings = provider.settings
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { chmod } from "node:fs/promises"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schedule } from "effect"
|
||||
import { Effect, Schedule, Schema } from "effect"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { ConfigProviderPlugin } from "@opencode/core/config/plugin/provider"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { AzurePlugin } from "@opencode/core/plugin/provider/azure"
|
||||
import { make } from "@opencode/core/plugin/provider/azure"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Document, Info } from "@opencode/schema/config"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decodeConfig = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
// Nothing listens here, so tests that are not about deployments never find any.
|
||||
const offline = { resource: () => "http://127.0.0.1:1/openai", management: "http://127.0.0.1:1" }
|
||||
|
||||
const addPlugin = Effect.fn(function* (endpoints = offline) {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* AzurePlugin.effect(host)
|
||||
yield* make(endpoints).effect(host)
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
@@ -29,6 +37,103 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function eventually<A, R>(
|
||||
effect: Effect.Effect<A, never, R>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 3000,
|
||||
): Effect.Effect<A, Error, R> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
type AzureRequest = {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly key: string | null
|
||||
readonly authorization: string | null
|
||||
readonly body: string
|
||||
}
|
||||
|
||||
function withAzure<A, E, R>(
|
||||
respond: (request: AzureRequest) => Response | Promise<Response>,
|
||||
fx: (input: { endpoints: typeof offline; requests: AzureRequest[] }) => Effect.Effect<A, E, R>,
|
||||
) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: AzureRequest[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const received = {
|
||||
method: request.method,
|
||||
path: url.pathname + url.search,
|
||||
key: request.headers.get("api-key"),
|
||||
authorization: request.headers.get("authorization"),
|
||||
body: await request.text(),
|
||||
}
|
||||
requests.push(received)
|
||||
return respond(received)
|
||||
},
|
||||
})
|
||||
return { requests, server }
|
||||
}),
|
||||
({ requests, server }) =>
|
||||
fx({
|
||||
requests,
|
||||
endpoints: { resource: () => `${server.url.origin}/openai`, management: server.url.origin },
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
)
|
||||
}
|
||||
|
||||
const seedCatalog = Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.update(Provider.ID.azure, (provider) => {
|
||||
provider.package = "@opencode/ai/providers/azure/responses"
|
||||
})
|
||||
editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5"), () => {})
|
||||
editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), (model) => {
|
||||
model.name = "GPT-5 Mini"
|
||||
model.limit = { context: 400_000, output: 128_000 }
|
||||
})
|
||||
editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), (model) => {
|
||||
model.name = "GPT-5 Nano"
|
||||
model.limit = { context: 300_000, output: 64_000 }
|
||||
})
|
||||
editor.models.update(Provider.ID.azure, Model.ID.make("deepseek-v4-flash"), (model) => {
|
||||
model.name = "DeepSeek-V4-Flash"
|
||||
model.package = "@opencode/ai/providers/openai-compatible"
|
||||
model.settings = { baseURL: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/models" }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const azureModels = Effect.gen(function* () {
|
||||
const models = yield* Model.Service
|
||||
return (yield* models.all())
|
||||
.filter((model) => model.providerID === Provider.ID.azure)
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
})
|
||||
|
||||
const cliTokens = (args: readonly string[]) => ({
|
||||
accessToken: `${args[args.indexOf("--scope") + 1]}-token`,
|
||||
expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
})
|
||||
|
||||
const account = (name: string) => ({
|
||||
id: `/subscriptions/sub/resourceGroups/rg-${name}/providers/Microsoft.CognitiveServices/accounts/${name}`,
|
||||
resourceName: name,
|
||||
resourceGroup: `rg-${name}`,
|
||||
location: "swedencentral",
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
@@ -100,6 +205,15 @@ const azureCredential = Effect.gen(function* () {
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
const keyCredential = Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
return yield* credentials.create({
|
||||
integrationID: Integration.ID.make("azure"),
|
||||
value: Credential.Key.make({ type: "key", key: "secret", configuration: { resourceName: "test-resource" } }),
|
||||
})
|
||||
})
|
||||
|
||||
describe("AzurePlugin", () => {
|
||||
it.effect("registers a resource name form when the environment does not provide one", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
@@ -216,15 +330,14 @@ describe("AzurePlugin", () => {
|
||||
access: "legacy-cli-token",
|
||||
metadata: { resourceName: "test-resource" },
|
||||
})
|
||||
expect(commands).toEqual([
|
||||
[
|
||||
"account",
|
||||
"get-access-token",
|
||||
"--scope",
|
||||
"https://cognitiveservices.azure.com/.default",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
// Discovery for the new connection may already be minting its management token afterwards.
|
||||
expect(commands[0]).toEqual([
|
||||
"account",
|
||||
"get-access-token",
|
||||
"--scope",
|
||||
"https://cognitiveservices.azure.com/.default",
|
||||
"--output",
|
||||
"json",
|
||||
])
|
||||
}),
|
||||
),
|
||||
@@ -236,30 +349,532 @@ describe("AzurePlugin", () => {
|
||||
return withAzureCommands(
|
||||
(args) => {
|
||||
commands.push([...args])
|
||||
return []
|
||||
return cliTokens(args)
|
||||
},
|
||||
() =>
|
||||
withAzure(
|
||||
(request) =>
|
||||
request.method === "POST"
|
||||
? Response.json({ data: [account("test-resource")] })
|
||||
: Response.json({
|
||||
value: [
|
||||
{ name: "gpt-5-mini", properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" } },
|
||||
],
|
||||
}),
|
||||
({ endpoints, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
yield* seedCatalog
|
||||
yield* azureCredential
|
||||
yield* addPlugin(endpoints)
|
||||
|
||||
// Startup serves the whole catalog; the deployments arrive afterwards without blocking it.
|
||||
expect(commands).toEqual([])
|
||||
expect(requests).toEqual([])
|
||||
expect((yield* catalog.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
|
||||
expect(yield* models.get(Provider.ID.azure, Model.ID.make("gpt-5-mini"))).toBeDefined()
|
||||
expect(yield* models.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeDefined()
|
||||
|
||||
const deployed = yield* eventually(azureModels, (list) => list.length === 1)
|
||||
expect(deployed.map((model) => model.id)).toEqual([Model.ID.make("gpt-5-mini")])
|
||||
expect(commands).toEqual([
|
||||
["account", "get-access-token", "--scope", "https://management.azure.com/.default", "--output", "json"],
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("does not refresh an expired Azure CLI token while starting", () => {
|
||||
const commands: string[][] = []
|
||||
return withAzureCommands(
|
||||
(args) => {
|
||||
commands.push([...args])
|
||||
return { accessToken: "refreshed-token", expires_on: Math.floor((Date.now() + 60 * 60 * 1000) / 1000) }
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("azure"),
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("azure-cli"),
|
||||
access: "expired-token",
|
||||
refresh: "azure-cli",
|
||||
expires: Date.now() - 60 * 60 * 1000,
|
||||
metadata: { resourceName: "test-resource" },
|
||||
}),
|
||||
})
|
||||
const catalog = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.update(Provider.ID.azure, (provider) => {
|
||||
provider.package = "@opencode/ai/providers/azure/responses"
|
||||
})
|
||||
editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-mini"), () => {})
|
||||
editor.models.update(Provider.ID.azure, Model.ID.make("gpt-5-nano"), () => {})
|
||||
})
|
||||
yield* azureCredential
|
||||
yield* addPlugin()
|
||||
|
||||
expect(commands).toEqual([])
|
||||
expect((yield* catalog.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
|
||||
expect(yield* models.get(Provider.ID.azure, Model.ID.make("gpt-5-mini"))).toBeDefined()
|
||||
expect(yield* models.get(Provider.ID.azure, Model.ID.make("gpt-5-nano"))).toBeDefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("narrows the catalog to the deployments listed with an API key", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
withAzure(
|
||||
() =>
|
||||
Response.json({
|
||||
data: [
|
||||
{ id: "gpt-5-mini-eu", model: "gpt-5-mini", status: "succeeded" },
|
||||
{ id: "gpt-5-mini", model: "gpt-5-mini", status: "succeeded" },
|
||||
{ id: "nano-b", model: "gpt-5-nano", status: "succeeded" },
|
||||
{ id: "nano-a", model: "gpt-5-nano", status: "succeeded" },
|
||||
{ id: "DeepSeek-V4-Flash", model: "DeepSeek-V4-Flash", status: "succeeded" },
|
||||
{ id: "gpt-5-pending", model: "gpt-5", status: "creating" },
|
||||
{ id: "fine-tuned", model: "gpt-4o-mini.ft-123", status: "succeeded" },
|
||||
{ id: 42 },
|
||||
],
|
||||
}),
|
||||
({ endpoints, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
yield* seedCatalog
|
||||
yield* keyCredential
|
||||
yield* addPlugin(endpoints)
|
||||
|
||||
const deployed = yield* eventually(azureModels, (list) => list.length === 5)
|
||||
expect(deployed.map((model) => [model.id, model.modelID, model.name])).toEqual([
|
||||
[Model.ID.make("deepseek-v4-flash"), Model.ID.make("DeepSeek-V4-Flash"), "DeepSeek-V4-Flash"],
|
||||
// The deployment named after its model keeps the model ID even though Azure listed another first.
|
||||
[Model.ID.make("gpt-5-mini"), Model.ID.make("gpt-5-mini"), "GPT-5 Mini"],
|
||||
[Model.ID.make("gpt-5-mini-eu"), Model.ID.make("gpt-5-mini-eu"), "GPT-5 Mini (gpt-5-mini-eu)"],
|
||||
// Custom deployment IDs are stable independently of the other deployments of the same model.
|
||||
[Model.ID.make("nano-a"), Model.ID.make("nano-a"), "GPT-5 Nano (nano-a)"],
|
||||
[Model.ID.make("nano-b"), Model.ID.make("nano-b"), "GPT-5 Nano (nano-b)"],
|
||||
])
|
||||
// A further deployment of a model keeps the catalog facts of that model.
|
||||
expect(deployed[2]?.limit).toEqual({ context: 400_000, output: 128_000 })
|
||||
// The resource name entered with the API key reaches catalog endpoints, not only the request route.
|
||||
expect(deployed[0]?.settings?.baseURL).toBe("https://test-resource.services.ai.azure.com/models")
|
||||
expect((yield* catalog.get(Provider.ID.azure))?.settings?.resourceName).toBe("test-resource")
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/openai/deployments?api-version=2022-12-01",
|
||||
key: "secret",
|
||||
authorization: null,
|
||||
body: "",
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("lists Azure CLI deployments through the management API", () => {
|
||||
const commands: string[][] = []
|
||||
const resource = account("test-resource")
|
||||
return withAzureCommands(
|
||||
(args) => {
|
||||
commands.push([...args])
|
||||
return cliTokens(args)
|
||||
},
|
||||
() =>
|
||||
withAzure(
|
||||
(request) => {
|
||||
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/resources"))
|
||||
return Response.json({ data: [resource] })
|
||||
return Response.json({
|
||||
value: [
|
||||
{
|
||||
name: "gpt-production",
|
||||
properties: { model: { format: "OpenAI", name: "gpt-5-mini" }, provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "gpt-5-nano",
|
||||
properties: { model: { format: "OpenAI", name: "gpt-5-nano" }, provisioningState: "Failed" },
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
({ endpoints, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedCatalog
|
||||
yield* azureCredential
|
||||
yield* addPlugin(endpoints)
|
||||
|
||||
const deployed = yield* eventually(azureModels, (list) => list.length === 1)
|
||||
expect(deployed.map((model) => [model.id, model.modelID])).toEqual([
|
||||
[Model.ID.make("gpt-production"), Model.ID.make("gpt-production")],
|
||||
])
|
||||
expect(commands).toEqual([
|
||||
["account", "get-access-token", "--scope", "https://management.azure.com/.default", "--output", "json"],
|
||||
])
|
||||
expect(requests.map((request) => [request.method, request.path, request.authorization])).toEqual([
|
||||
[
|
||||
"POST",
|
||||
"/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01",
|
||||
"Bearer https://management.azure.com/.default-token",
|
||||
],
|
||||
[
|
||||
"GET",
|
||||
`${resource.id}/deployments?api-version=2024-10-01`,
|
||||
"Bearer https://management.azure.com/.default-token",
|
||||
],
|
||||
])
|
||||
expect(requests[0]?.body).toContain("resourceName =~ 'test-resource'")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("falls back to the resource inventory when the Azure CLI cannot read the management API", () =>
|
||||
withAzureCommands(cliTokens, () =>
|
||||
withAzure(
|
||||
(request) =>
|
||||
request.path.startsWith("/openai/deployments")
|
||||
? Response.json({ data: [{ id: "gpt-5-mini", model: "gpt-5-mini", status: "succeeded" }] })
|
||||
: new Response("Forbidden", { status: 403 }),
|
||||
({ endpoints, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedCatalog
|
||||
yield* azureCredential
|
||||
yield* addPlugin(endpoints)
|
||||
|
||||
const deployed = yield* eventually(azureModels, (list) => list.length === 1)
|
||||
expect(deployed.map((model) => model.id)).toEqual([Model.ID.make("gpt-5-mini")])
|
||||
expect(requests.map((request) => [request.method, request.path, request.authorization])).toEqual([
|
||||
[
|
||||
"POST",
|
||||
"/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01",
|
||||
"Bearer https://management.azure.com/.default-token",
|
||||
],
|
||||
["GET", "/openai/deployments?api-version=2022-12-01", "Bearer stored-token"],
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps the catalog when deployments cannot be listed", () =>
|
||||
withAzure(
|
||||
() => new Response("Unavailable", { status: 503 }),
|
||||
({ endpoints, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedCatalog
|
||||
yield* keyCredential
|
||||
yield* addPlugin(endpoints)
|
||||
|
||||
yield* eventually(Effect.succeed(requests), (list) => list.length === 1)
|
||||
yield* Effect.promise(() => Bun.sleep(25))
|
||||
expect((yield* azureModels).map((model) => model.id)).toEqual([
|
||||
Model.ID.make("deepseek-v4-flash"),
|
||||
Model.ID.make("gpt-5"),
|
||||
Model.ID.make("gpt-5-mini"),
|
||||
Model.ID.make("gpt-5-nano"),
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps the listed deployments while the Azure CLI cannot refresh its token", () => {
|
||||
const commands: string[][] = []
|
||||
return withAzureCommands(
|
||||
(args) => {
|
||||
commands.push([...args])
|
||||
return new Error("az: please run 'az login' to setup account")
|
||||
},
|
||||
() =>
|
||||
withAzure(
|
||||
() => Response.json({ data: [{ id: "gpt-5-mini", model: "gpt-5-mini", status: "succeeded" }] }),
|
||||
({ endpoints }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* seedCatalog
|
||||
const credential = yield* azureCredential
|
||||
yield* addPlugin(endpoints)
|
||||
yield* eventually(azureModels, (list) => list.length === 1)
|
||||
|
||||
// A failed refresh of the same account retains its last successful inventory.
|
||||
yield* credentials.update(credential.id, {
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("azure-cli"),
|
||||
access: "expired-token",
|
||||
refresh: "azure-cli",
|
||||
expires: Date.now() - 60 * 60 * 1000,
|
||||
metadata: { resourceName: "test-resource" },
|
||||
}),
|
||||
})
|
||||
yield* bus.publish(
|
||||
Credential.Event.Switched,
|
||||
{ integrationID: Integration.ID.make("azure"), credentialID: credential.id },
|
||||
{ global: true },
|
||||
)
|
||||
yield* eventually(Effect.succeed(commands), (list) => list.length > 0)
|
||||
yield* Effect.promise(() => Bun.sleep(25))
|
||||
|
||||
expect((yield* azureModels).map((model) => model.id)).toEqual([Model.ID.make("gpt-5-mini")])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("rebinds a switched account without waiting for the previous account's discovery", () => {
|
||||
const pending = Promise.withResolvers<Response>()
|
||||
const calls: AzureRequest[] = []
|
||||
return withAzure(
|
||||
(request) => {
|
||||
calls.push(request)
|
||||
if (calls.length === 2) return pending.promise
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: request.key === "secret" ? "production-a" : "production-b",
|
||||
model: "gpt-5-mini",
|
||||
status: "succeeded",
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
({ endpoints }) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const providers = yield* Provider.Service
|
||||
yield* seedCatalog
|
||||
const first = yield* keyCredential
|
||||
yield* addPlugin(endpoints)
|
||||
const previous = yield* eventually(azureModels, (list) => list.some((model) => model.id === "production-a"))
|
||||
|
||||
yield* bus.publish(
|
||||
Credential.Event.Switched,
|
||||
{ integrationID: Integration.ID.make("azure"), credentialID: first.id },
|
||||
{ global: true },
|
||||
)
|
||||
yield* eventually(Effect.succeed(calls), (list) => list.length === 2)
|
||||
const next = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("azure"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "other-key",
|
||||
configuration: { resourceName: "other-resource" },
|
||||
}),
|
||||
})
|
||||
|
||||
// The previous account's discovery is still waiting on Azure, yet the new account is already listed.
|
||||
const deployed = yield* eventually(azureModels, (list) => list.length === 1 && list[0]?.id === "production-b")
|
||||
expect(deployed[0]?.settings?.resourceName).toBe("other-resource")
|
||||
expect((yield* providers.available()).some((provider) => provider.id === Provider.ID.azure)).toBe(true)
|
||||
expect((yield* providers.snapshot()).records.get(Provider.ID.azure)?.sourceConnection).toMatchObject({
|
||||
type: "credential",
|
||||
id: next.id,
|
||||
})
|
||||
expect(previous.map((model) => model.id)).toEqual([Model.ID.make("production-a")])
|
||||
|
||||
// The interrupted discovery never publishes the previous account's late answer.
|
||||
pending.resolve(Response.json({ data: [{ id: "stale", model: "gpt-5-nano", status: "succeeded" }] }))
|
||||
yield* Effect.promise(() => Bun.sleep(25))
|
||||
expect((yield* azureModels).map((model) => model.id)).toEqual([Model.ID.make("production-b")])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("keeps deployment IDs when another deployment of the same model is removed", () => {
|
||||
const inventory = { names: ["nano-a", "nano-b"] }
|
||||
return withAzure(
|
||||
() => Response.json({ data: inventory.names.map((id) => ({ id, model: "gpt-5-nano", status: "succeeded" })) }),
|
||||
({ endpoints }) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* seedCatalog
|
||||
const credential = yield* keyCredential
|
||||
yield* addPlugin(endpoints)
|
||||
const before = yield* eventually(azureModels, (list) => list.length === 2)
|
||||
expect(before.map((model) => model.id)).toEqual([Model.ID.make("nano-a"), Model.ID.make("nano-b")])
|
||||
|
||||
inventory.names = ["nano-b"]
|
||||
yield* bus.publish(
|
||||
Credential.Event.Switched,
|
||||
{ integrationID: Integration.ID.make("azure"), credentialID: credential.id },
|
||||
{ global: true },
|
||||
)
|
||||
const after = yield* eventually(azureModels, (list) => list.length === 1)
|
||||
expect(after).toEqual([before[1]])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("loads all management deployment pages before publishing the inventory", () => {
|
||||
const pending = Promise.withResolvers<Response>()
|
||||
const state = { url: "" }
|
||||
return withAzureCommands(cliTokens, () =>
|
||||
withAzure(
|
||||
(request) => {
|
||||
if (request.path.startsWith("/openai/deployments")) return new Response("Not found", { status: 404 })
|
||||
if (request.method === "POST") return Response.json({ data: [account("test-resource")] })
|
||||
if (request.path === "/page-2") return pending.promise
|
||||
return Response.json({
|
||||
nextLink: `${state.url}/page-2`,
|
||||
value: [{ name: "mini", properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" } }],
|
||||
})
|
||||
},
|
||||
({ endpoints, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
state.url = endpoints.management
|
||||
yield* seedCatalog
|
||||
yield* azureCredential
|
||||
yield* addPlugin(endpoints)
|
||||
yield* eventually(Effect.succeed(requests), (list) => list.length === 3)
|
||||
expect(requests[2]).toMatchObject({
|
||||
path: "/page-2",
|
||||
authorization: "Bearer https://management.azure.com/.default-token",
|
||||
})
|
||||
expect(yield* azureModels).toHaveLength(4)
|
||||
pending.resolve(
|
||||
Response.json({
|
||||
value: [
|
||||
{ name: "nano", properties: { model: { name: "gpt-5-nano" }, provisioningState: "Succeeded" } },
|
||||
],
|
||||
}),
|
||||
)
|
||||
const deployed = yield* eventually(azureModels, (list) => list.length === 2)
|
||||
expect(deployed.map((model) => model.modelID)).toEqual([Model.ID.make("mini"), Model.ID.make("nano")])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("keeps the last complete inventory when a later management page fails", () => {
|
||||
const state = { fail: false, url: "" }
|
||||
return withAzureCommands(cliTokens, () =>
|
||||
withAzure(
|
||||
(request) => {
|
||||
if (request.path.startsWith("/openai/deployments")) return new Response("Not found", { status: 404 })
|
||||
if (request.method === "POST") return Response.json({ data: [account("test-resource")] })
|
||||
if (request.path === "/page-2") return new Response("Unavailable", { status: 503 })
|
||||
if (state.fail)
|
||||
return Response.json({
|
||||
nextLink: `${state.url}/page-2`,
|
||||
value: [{ name: "nano", properties: { model: { name: "gpt-5-nano" }, provisioningState: "Succeeded" } }],
|
||||
})
|
||||
return Response.json({
|
||||
value: [{ name: "mini", properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" } }],
|
||||
})
|
||||
},
|
||||
({ endpoints, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
state.url = endpoints.management
|
||||
const bus = yield* Bus.Service
|
||||
yield* seedCatalog
|
||||
const credential = yield* azureCredential
|
||||
yield* addPlugin(endpoints)
|
||||
yield* eventually(azureModels, (list) => list.length === 1 && list[0]?.id === "mini")
|
||||
state.fail = true
|
||||
yield* bus.publish(
|
||||
Credential.Event.Switched,
|
||||
{ integrationID: Integration.ID.make("azure"), credentialID: credential.id },
|
||||
{ global: true },
|
||||
)
|
||||
// The cached resource skips Resource Graph; the failed page then falls back to the resource inventory.
|
||||
yield* eventually(Effect.succeed(requests), (list) => list.length === 5)
|
||||
expect(requests.map((request) => request.path)).toEqual([
|
||||
"/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01",
|
||||
`${account("test-resource").id}/deployments?api-version=2024-10-01`,
|
||||
`${account("test-resource").id}/deployments?api-version=2024-10-01`,
|
||||
"/page-2",
|
||||
"/openai/deployments?api-version=2022-12-01",
|
||||
])
|
||||
yield* Effect.promise(() => Bun.sleep(25))
|
||||
expect((yield* azureModels).map((model) => model.id)).toEqual([Model.ID.make("mini")])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("does not follow a management page outside the management endpoint", () =>
|
||||
withAzureCommands(cliTokens, () =>
|
||||
withAzure(
|
||||
(request) => {
|
||||
if (request.path.startsWith("/openai/deployments")) return new Response("Not found", { status: 404 })
|
||||
if (request.method === "POST") return Response.json({ data: [account("test-resource")] })
|
||||
return Response.json({
|
||||
nextLink: "https://attacker.example/page-2",
|
||||
value: [{ name: "mini", properties: { model: { name: "gpt-5-mini" }, provisioningState: "Succeeded" } }],
|
||||
})
|
||||
},
|
||||
({ endpoints, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedCatalog
|
||||
yield* azureCredential
|
||||
yield* addPlugin(endpoints)
|
||||
|
||||
yield* eventually(Effect.succeed(requests), (list) => list.length === 3)
|
||||
yield* Effect.promise(() => Bun.sleep(25))
|
||||
expect(requests[2]?.path).toBe("/openai/deployments?api-version=2022-12-01")
|
||||
// A partial inventory is never published in place of the catalog.
|
||||
expect(yield* azureModels).toHaveLength(4)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps the catalog for a custom endpoint", () =>
|
||||
withAzure(
|
||||
() => Response.json({ data: [{ id: "gpt-5-mini", model: "gpt-5-mini", status: "succeeded" }] }),
|
||||
({ endpoints, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
yield* seedCatalog
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.update(Provider.ID.azure, (provider) => {
|
||||
provider.settings = { baseURL: "https://gateway.example/azure" }
|
||||
})
|
||||
})
|
||||
yield* keyCredential
|
||||
yield* addPlugin(endpoints)
|
||||
|
||||
yield* Effect.promise(() => Bun.sleep(50))
|
||||
expect(requests).toEqual([])
|
||||
expect(yield* azureModels).toHaveLength(4)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("leaves a model that is configured explicitly to the configuration", () =>
|
||||
withAzure(
|
||||
() => Response.json({ data: [{ id: "gpt-5-mini", model: "gpt-5-mini", status: "succeeded" }] }),
|
||||
({ endpoints }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedCatalog
|
||||
yield* keyCredential
|
||||
yield* addPlugin(endpoints)
|
||||
const plugin = yield* Plugin.Service
|
||||
yield* ConfigProviderPlugin.Plugin.effect(yield* PluginHost.make(plugin)).pipe(
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decodeConfig({
|
||||
providers: { azure: { models: { "gpt-5-nano": { modelID: "nano-production" } } } },
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
const deployed = yield* eventually(azureModels, (list) => list.length === 2)
|
||||
expect(deployed.map((model) => [model.id, model.modelID, model.name, model.limit.context])).toEqual([
|
||||
[Model.ID.make("gpt-5-mini"), Model.ID.make("gpt-5-mini"), "GPT-5 Mini", 400_000],
|
||||
[Model.ID.make("gpt-5-nano"), Model.ID.make("nano-production"), "GPT-5 Nano", 300_000],
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses the correct bearer token audience for Azure and Foundry requests", () =>
|
||||
withAzureCommands(
|
||||
(args) => {
|
||||
@@ -319,6 +934,21 @@ describe("AzurePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers the connection's resource over configuration, as requests do", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.update(Provider.ID.azure, (provider) => {
|
||||
provider.package = "@opencode/ai/providers/azure/responses"
|
||||
provider.settings = { resourceName: "from-config" }
|
||||
})
|
||||
})
|
||||
yield* keyCredential
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.get(Provider.ID.azure)).settings?.resourceName).toBe("test-resource")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves resourceName from env", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -473,7 +1103,14 @@ describe("AzurePlugin", () => {
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.get(Provider.ID.azure))?.settings?.transport).toBe("websocket")
|
||||
for (const modelID of [models.responses, models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
|
||||
for (const modelID of [
|
||||
models.responses,
|
||||
models.chat,
|
||||
models.preview,
|
||||
models.deploymentURL,
|
||||
models.gateway,
|
||||
models.nonAzure,
|
||||
]) {
|
||||
const model = required(yield* service.get(Provider.ID.azure, modelID))
|
||||
expect(model.settings?.transport).toBeUndefined()
|
||||
}
|
||||
|
||||
@@ -268,7 +268,11 @@ choose **Microsoft Entra ID (Azure CLI)** when connecting Azure.
|
||||
az login
|
||||
```
|
||||
|
||||
For a resource in another tenant or subscription, select both explicitly.
|
||||
Enter the resource name when connecting if it is not already supplied by configuration or the environment. A resource
|
||||
name saved with a connection takes precedence over configuration; connect again to change it.
|
||||
|
||||
Azure CLI connections use the account selected in the Azure CLI. For a resource in another tenant or subscription,
|
||||
select both explicitly.
|
||||
|
||||
```bash
|
||||
az login --tenant TENANT_ID
|
||||
@@ -278,8 +282,28 @@ az account set --subscription NAME_OR_ID
|
||||
Instead of configuration, `AZURE_RESOURCE_NAME` supplies the resource name to the OpenCode server. The legacy
|
||||
`AZURE_COGNITIVE_SERVICES_RESOURCE_NAME` variable also works.
|
||||
|
||||
OpenCode does not query Azure management APIs or discover deployments. If a deployment does not match its catalog
|
||||
model name, map an OpenCode model ID to the deployment with `modelID`.
|
||||
### Deployments
|
||||
|
||||
Azure serves a model only through a deployment, so OpenCode lists the deployments of your resource and shows those
|
||||
instead of the whole Azure catalog. The list loads in the background after startup, after connecting, and after
|
||||
switching accounts. To pick up a deployment added later, restart OpenCode or connect again.
|
||||
|
||||
- Until the list loads for a connection, or if it fails, the whole catalog stays available.
|
||||
- A failed reload for the same connection keeps its last complete inventory.
|
||||
- Switching accounts never shows the previous account's inventory; the new account starts from the whole catalog.
|
||||
- Deployments inherit limits, costs, and capabilities from their catalog models.
|
||||
|
||||
Deployment names are their model IDs. A name matching its catalog model, ignoring case, keeps the catalog ID;
|
||||
other names appear as IDs such as `azure/gpt-production`. These IDs stay stable when other deployments are added or
|
||||
removed.
|
||||
|
||||
With the Azure CLI, OpenCode lists deployments through the Azure management API, which needs read access to the
|
||||
resource. If that fails, and always with an API key, it uses the resource's legacy deployment inventory. Discovery reads
|
||||
every page before publishing the list. A custom `settings.baseURL` keeps the catalog.
|
||||
|
||||
Configure a deployment explicitly when its model is not in the catalog, such as a fine-tuned model, or to map a model ID
|
||||
to a deployment yourself. OpenCode logs a warning naming such deployments. Explicitly configured models are always
|
||||
kept.
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user