feat(core): connect Azure through the Azure CLI as an external credential (#53794)

This commit is contained in:
Filip
2026-10-07 23:10:18 +02:00
committed by GitHub
Unverified
parent 2138bfd02a
commit 87a1ebcc97
8 changed files with 315 additions and 79 deletions
+2
View File
@@ -47,6 +47,7 @@ import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
import m47 from "./migration/20260923013825_project_time_active.js"
import m48 from "./migration/20261007190000_azure_cli_external_credential.js"
export const migrations = [
m00,
@@ -97,4 +98,5 @@ export const migrations = [
m45,
m46,
m47,
m48,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,21 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
// Azure CLI connections were OAuth credentials holding a copy of the CLI's token; they now reference the CLI as an
// external credential source. Dropping the token fields keeps the method and the resource in metadata.
const migration: DatabaseMigration.Migration = {
id: "20261007190000_azure_cli_external_credential",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
UPDATE \`credential\`
SET \`value\` = json_remove(json_set(\`value\`, '$.type', 'external'), '$.access', '$.refresh', '$.expires')
WHERE \`integration_id\` = 'azure'
AND json_extract(\`value\`, '$.type') = 'oauth'
AND json_extract(\`value\`, '$.methodID') = 'azure-cli'
`)
})
},
}
export default migration
+20 -10
View File
@@ -207,7 +207,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const settings = yield* prepareProviderSettings(
resolved,
Provider.mergeOverlay(resolved.settings, {
...nativeCredentialSettings(resolved.package ?? "", credential),
...nativeCredentialSettings(resolved, resolved.package ?? "", credential),
...credential?.metadata,
...configuration,
}) ?? {},
@@ -225,7 +225,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const settings = {
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
...nativeCredentialSettings(specifier, credential),
...nativeCredentialSettings(resolved, specifier, credential),
headers: resolved.headers,
body: resolved.body,
}
@@ -282,7 +282,10 @@ function prepareProviderSettings(
)
}
function prepareProviderURL(model: RuntimeInfo, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
function prepareProviderURL(
model: RuntimeInfo,
baseURL: string,
): Effect.Effect<string, UnresolvedProviderVariablesError> {
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
const failure = unresolvedProviderVariables(model, prepared)
@@ -299,20 +302,27 @@ function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
})
}
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
const nativeCredentialSettings = (model: RuntimeInfo, specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
// The saved profile reaches the package through metadata; SigV4 keeps an ambient bearer token from taking over.
if (credential.type === "external")
return specifier.startsWith("@opencode/ai/providers/amazon-bedrock") ? { auth: "sigv4" } : {}
if (credential.type === "key") return { apiKey: credential.key }
if (credential.type === "oauth") return tokenSettings(specifier, credential.access)
// The saved profile reaches the package through metadata; SigV4 keeps an ambient bearer token from taking over.
if (specifier.startsWith("@opencode/ai/providers/amazon-bedrock")) return { auth: "sigv4" }
// The Azure plugin's request hooks replace this with an Entra ID token from the Azure CLI; it only gets the
// request past the package's own credential check.
if (model.providerID === Provider.ID.azure) return tokenSettings(specifier, "azure-cli")
return {}
}
const tokenSettings = (specifier: string, token: string) => {
if (specifier === "@opencode/ai/providers/anthropic" || specifier === "@opencode/ai/providers/anthropic-compatible")
return { authToken: credential.access }
return { authToken: token }
if (
specifier === "@opencode/ai/providers/google-vertex" ||
specifier.startsWith("@opencode/ai/providers/google-vertex/")
)
return { accessToken: credential.access }
return { apiKey: credential.access }
return { accessToken: token }
return { apiKey: token }
}
const unsupported = (model: RuntimeInfo) =>
+112 -52
View File
@@ -1,8 +1,11 @@
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 path from "node:path"
import { define } from "@opencode/plugin/effect/plugin"
import { Form } from "@opencode/schema/form"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { App } from "../../app.js"
import { Bus } from "../../bus.js"
@@ -49,8 +52,18 @@ const decodeManagementDeployment = Schema.decodeUnknownOption(
}),
}),
)
const ResourceQuery = Schema.Struct({ query: Schema.String })
const ResourceQuery = Schema.Struct({
query: Schema.String,
options: Schema.optional(Schema.Struct({ $top: Schema.Number })),
})
const Resources = Schema.Struct({ data: Schema.Array(Schema.Struct({ id: Schema.NonEmptyString })) })
const ResourceListing = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeProfile = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.NonEmptyArray(Schema.Unknown) })),
)
const decodeResourceListing = Schema.decodeUnknownOption(
Schema.Struct({ resourceName: Schema.NonEmptyString, resourceGroup: Schema.String, location: Schema.String }),
)
type Deployment = { readonly name: string; readonly model: string }
@@ -65,6 +78,7 @@ export function make(
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(Provider.ID.azure)
const processes = yield* AppProcess.Service
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const providers = yield* Provider.Service
@@ -118,61 +132,59 @@ export function make(
)
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 configuredResource = Boolean(resolveResourceName(configured) || typeof configured?.baseURL === "string")
// Undefined until the Azure CLI answers; a failed lookup leaves the form to manual entry.
const listing: { resources?: readonly Form.Option[] } = {}
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: Provider.ID.azure,
method: { type: "key", label: "API key", form: form() },
method: {
type: "key",
label: "API key",
form: configuredResource
? undefined
: Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
},
]),
},
})
if (!available) return
editor.method.update({
integrationID: Provider.ID.azure,
method: {
id: methodID,
type: "oauth",
type: "external",
label: "Microsoft Entra ID (Azure CLI)",
form: form(),
form: configuredResource
? undefined
: Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Azure resource",
placeholder: "e.g. my-models",
required: true,
pattern: resourcePattern.source,
// Resources are listed once at startup, so one created later is typed in. Without a list the field
// is a plain text input.
...iife(() => {
if (!listing.resources) return { description: "Requests use your `az login` session." }
if (listing.resources.length === 0)
return {
description: "No Azure OpenAI or AI Services resources found for your `az login` account.",
}
return { custom: true, options: listing.resources }
}),
},
]),
},
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 }),
),
),
})
})
@@ -235,7 +247,7 @@ export function make(
const resourceDeployments = Effect.fn("AzurePlugin.resourceDeployments")(function* (
url: string,
credential: Credential.Key | Credential.OAuth,
credential: Credential.Key | Credential.External,
) {
return yield* http
.execute(
@@ -244,7 +256,7 @@ export function make(
HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)),
credential.type === "key"
? HttpClientRequest.setHeader("api-key", credential.key)
: HttpClientRequest.bearerToken(credential.access),
: HttpClientRequest.bearerToken((yield* token(cognitiveScope)).access),
),
)
.pipe(
@@ -266,8 +278,8 @@ export function make(
// data-plane version 2022-12-01 has it; later versions dropped `/deployments` and keep `/models`, which lists
// models the resource can deploy rather than its deployments.
// https://github.com/Azure/azure-rest-api-specs/blob/main/specification/cognitiveservices/data-plane/OpenAIAuthoring/stable/2022-12-01/azureopenai.json
const deployments = (url: string, resource: string, credential: Credential.Key | Credential.OAuth) =>
credential.type === "oauth"
const deployments = (url: string, resource: string, credential: Credential.Key | Credential.External) =>
credential.type === "external"
? managementDeployments(resource).pipe(Effect.catch(() => resourceDeployments(url, credential)))
: resourceDeployments(url, credential)
@@ -310,8 +322,8 @@ export function make(
.pipe(Effect.orElseSucceed(() => undefined))
if (
!credential ||
credential.type === "external" ||
(credential.type === "oauth" && credential.methodID !== methodID)
credential.type === "oauth" ||
(credential.type === "external" && credential.methodID !== methodID)
)
return
const found = yield* deployments(url, name, credential).pipe(
@@ -353,6 +365,43 @@ export function make(
: resolveResourceName(provider.settings, loaded.resource)
Object.assign(loaded, yield* load())
// Lists the resources the Azure CLI account can reach in the background, so the connect form can offer them
// without startup waiting on Azure. The CLI's profile shows a signed-in account without running the CLI.
// https://learn.microsoft.com/cli/azure/azure-cli-configuration#cli-configuration-file
const listResources = Effect.fn("AzurePlugin.listResources")(function* () {
const profile = yield* fs.readFileStringSafe(
path.join(process.env.AZURE_CONFIG_DIR ?? path.join(Global.Path.home, ".azure"), "azureProfile.json"),
)
// The Azure CLI writes the profile with a byte order mark.
if (Option.isNone(decodeProfile(profile?.replace(/^\uFEFF/, "")))) return
const response = yield* HttpClientRequest.post(
`${endpoints.management}/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01`,
).pipe(
HttpClientRequest.schemaBodyJson(ResourceQuery)({ query: resourceListQuery, options: { $top: 1000 } }),
Effect.flatMap(management),
Effect.flatMap(HttpClientResponse.schemaBodyJson(ResourceListing)),
Effect.timeout("10 seconds"),
)
listing.resources = response.data.flatMap((raw): Form.Option[] => {
const item = Option.getOrUndefined(decodeResourceListing(raw))
if (!item || !resourcePattern.test(item.resourceName)) return []
return [
{
value: item.resourceName,
label: item.resourceName,
description: `${item.resourceGroup} · ${item.location}`,
},
]
})
yield* ctx.integration.reload()
})
if (available && !configuredResource)
yield* listResources().pipe(
Effect.catch((cause) => Effect.logDebug("failed to list Azure resources", { cause })),
Effect.forkScoped,
)
yield* ctx.provider.transform((evt) => {
for (const item of evt.list()) {
if (
@@ -432,7 +481,7 @@ export function make(
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
if (credential?.type !== "external" || credential.methodID !== methodID) return
const target = new URL(url)
const scope =
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
@@ -493,12 +542,23 @@ function credentialResource(credential: Credential.Value | undefined) {
const resource =
credential?.type === "key"
? (credential.configuration?.resourceName ?? credential.metadata?.resourceName)
: credential?.methodID === methodID
: credential?.type === "external" && credential.methodID === methodID
? credential.metadata?.resourceName
: undefined
return typeof resource === "string" && resource.trim() !== "" ? resource : undefined
}
// Entra ID authentication requires a custom subdomain, which is the resource name every endpoint uses.
// https://learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains
const resourceListQuery = [
"resources",
"| where type =~ 'microsoft.cognitiveservices/accounts' and kind in~ ('AIServices', 'OpenAI')",
"| extend resourceName = tostring(properties.customSubDomainName)",
"| where isnotempty(resourceName)",
"| project resourceName, resourceGroup, location",
"| order by resourceName asc",
].join(" ")
function resourceQuery(resource: string) {
return [
"resources",
@@ -20,6 +20,7 @@ import workspaceMigration from "@opencode/core/database/migration/20260808023530
import executionClaimsMigration from "@opencode/core/database/migration/20260811161259_execution_claim_attempts"
import sessionInboxMigration from "@opencode/core/database/migration/20260812181746_session_inbox"
import sessionViewedStateMigration from "@opencode/core/database/migration/20260819222447_session_viewed_state"
import azureCliCredentialMigration from "@opencode/core/database/migration/20261007190000_azure_cli_external_credential"
import { Global } from "@opencode/util/global"
const run = <A, E>(
@@ -531,6 +532,37 @@ describe("DatabaseMigration", () => {
)
})
test("moves Azure CLI connections saved as OAuth credentials to external credentials", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
const now = Date.now()
const oauth = { type: "oauth", methodID: "azure-cli", access: "token", refresh: "azure-cli", expires: 123 }
yield* db.run(sql`
INSERT INTO credential (id, integration_id, label, value, time_created, time_updated) VALUES
('cli', 'azure', 'Azure CLI', ${JSON.stringify({ ...oauth, metadata: { resourceName: "my-models" } })}, ${now}, ${now}),
('cli-configured', 'azure', 'Azure CLI', ${JSON.stringify(oauth)}, ${now}, ${now}),
('key', 'azure', 'API key', ${JSON.stringify({ type: "key", key: "secret" })}, ${now}, ${now}),
('other', 'openai', 'OAuth', ${JSON.stringify(oauth)}, ${now}, ${now})
`)
yield* db.run(sql`DELETE FROM migration WHERE id = ${azureCliCredentialMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [azureCliCredentialMigration])
expect(yield* db.all(sql`SELECT id, value FROM credential ORDER BY id`)).toEqual([
{
id: "cli",
value: JSON.stringify({ type: "external", methodID: "azure-cli", metadata: { resourceName: "my-models" } }),
},
{ id: "cli-configured", value: JSON.stringify({ type: "external", methodID: "azure-cli" }) },
{ id: "key", value: JSON.stringify({ type: "key", key: "secret" }) },
{ id: "other", value: JSON.stringify(oauth) },
])
}),
)
})
test("rolls back a failed migration without recording it", async () => {
await run(
Effect.gen(function* () {
+37
View File
@@ -732,6 +732,43 @@ describe("ModelResolver", () => {
}),
)
it.effect("lets Azure CLI requests past package auth for the Azure plugin to authorize", () =>
withConfigEnv({}, () =>
Effect.gen(function* () {
const credential = Credential.External.make({
type: "external",
methodID: Integration.MethodID.make("azure-cli"),
metadata: { resourceName: "cli-resource" },
})
const azure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), { providerID: Provider.ID.azure, headers: {}, body: {} }),
credential,
)
const foundry = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/anthropic"), {
providerID: Provider.ID.azure,
settings: { baseURL: "https://cli-resource.services.ai.azure.com/anthropic/v1" },
headers: {},
body: {},
}),
credential,
)
const authorize = (resolved: LanguageModel) =>
resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://cli-resource.openai.azure.com/openai/v1/responses",
body: "{}",
headers: Headers.empty,
})
expect(azure.route.endpoint.baseURL).toBe("https://cli-resource.openai.azure.com/openai/v1")
expect((yield* authorize(azure))["api-key"]).toBe("azure-cli")
expect((yield* authorize(foundry)).authorization).toBe("Bearer azure-cli")
}),
),
)
it.effect("does not project API key metadata into the request body", () =>
Effect.gen(function* () {
// V1 auth.json stored connect-form answers as API key metadata, and the legacy import preserves them there.
@@ -64,13 +64,13 @@ type AzureRequest = {
type Route = (request: AzureRequest) => Response | Promise<Response>
// Answers like Azure: the resource's own deployment list, Resource Graph, and the management API.
const fakeAzure = (routes: { resource?: Route; management?: Route }) =>
const fakeAzure = (routes: { resource?: Route; management?: Route; resources?: () => unknown[] }) =>
Effect.acquireRelease(
Effect.sync(() => {
const requests: AzureRequest[] = []
const server = Bun.serve({
port: 0,
fetch: (raw) => {
fetch: async (raw) => {
const url = new URL(raw.url)
const request = {
method: raw.method,
@@ -81,8 +81,12 @@ const fakeAzure = (routes: { resource?: Route; management?: Route }) =>
}
requests.push(request)
if (request.path.startsWith("/openai/deployments")) return routes.resource?.(request) ?? notFound()
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/"))
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/")) {
// The connect form lists every resource; discovery looks up one resource's ID.
if ((await raw.text()).includes("project resourceName"))
return routes.resources ? Response.json({ data: routes.resources() }) : unauthorized()
return Response.json({ data: [{ id: resourceID }] })
}
return routes.management?.(request) ?? notFound()
},
})
@@ -121,7 +125,14 @@ const fakeAzureCli = Effect.fn(function* (respond: (args: readonly string[]) =>
const executable = `${directory}/${windows ? "az.cmd" : "az"}`
yield* Effect.promise(() => Bun.write(executable, windows ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n"))
yield* Effect.promise(() => chmod(executable, 0o755))
yield* setEnv({ PATH: `${directory}${windows ? ";" : ":"}${process.env.PATH}` })
// The CLI writes its profile with a byte order mark.
yield* Effect.promise(() =>
Bun.write(`${directory}/azure/azureProfile.json`, `\uFEFF${JSON.stringify({ subscriptions: [{ id: "sub" }] })}`),
)
yield* setEnv({
PATH: `${directory}${windows ? ";" : ":"}${process.env.PATH}`,
AZURE_CONFIG_DIR: `${directory}/azure`,
})
const commands: string[][] = []
const fake = AppProcess.Service.of({
...processes,
@@ -146,13 +157,10 @@ const fakeAzureCli = Effect.fn(function* (respond: (args: readonly string[]) =>
}
})
const cliCredential = (options: { access?: string; expires?: number } = {}) =>
Credential.OAuth.make({
type: "oauth",
const cliCredential = () =>
Credential.External.make({
type: "external",
methodID: Integration.MethodID.make("azure-cli"),
access: options.access ?? "stored-token",
refresh: "azure-cli",
expires: options.expires ?? Date.now() + hour,
metadata: { resourceName: "test-resource" },
})
@@ -231,18 +239,84 @@ describe("AzurePlugin connecting", () => {
yield* setEnv({ PATH: "/nonexistent" })
yield* addPlugin()
const integrations = yield* Integration.Service
expect(required(yield* integrations.get(azureID)).methods.some((method) => method.type === "oauth")).toBe(false)
expect(required(yield* integrations.get(azureID)).methods.some((method) => method.type === "external")).toBe(
false,
)
}),
)
it.live("offers the resources the Azure CLI can reach and accepts a typed resource name", () =>
Effect.gen(function* () {
yield* setEnv(noResourceEnv)
const cli = yield* fakeAzureCli()
const azure = yield* fakeAzure({
resources: () => [
{ resourceName: "alpha", resourceGroup: "models", location: "eastus" },
{ resourceName: "not a hostname", resourceGroup: "models", location: "eastus" },
{ id: 42 },
],
})
yield* addPlugin(azure.endpoints).pipe(cli.provide)
const integrations = yield* Integration.Service
const field = Effect.gen(function* () {
const method = required(yield* integrations.get(azureID)).methods.find((method) => method.type === "external")
const field = method?.type === "external" ? method.form?.[0] : undefined
return field?.type === "string" ? field : undefined
})
const listed = required(yield* eventually(field, (field) => (field?.options?.length ?? 0) > 0))
expect(listed).toMatchObject({
key: "resourceName",
custom: true,
options: [{ value: "alpha", label: "alpha", description: "models · eastus" }],
})
const external = (resourceName: string) =>
integrations.connection.external({
integrationID: azureID,
methodID: Integration.MethodID.make("azure-cli"),
answer: { resourceName },
})
expect(yield* external("not a hostname").pipe(Effect.flip)).toBeInstanceOf(Integration.AuthorizationError)
yield* external("typed-resource")
const credentials = yield* Credential.Service
expect((yield* credentials.list(azureID)).map((item) => item.value)).toEqual([
Credential.External.make({
type: "external",
methodID: Integration.MethodID.make("azure-cli"),
metadata: { resourceName: "typed-resource" },
}),
])
}),
)
it.live("asks for the resource name as text when the Azure CLI lists no resources", () =>
Effect.gen(function* () {
yield* setEnv(noResourceEnv)
const cli = yield* fakeAzureCli()
const azure = yield* fakeAzure({ resources: () => [] })
yield* addPlugin(azure.endpoints).pipe(cli.provide)
const integrations = yield* Integration.Service
const field = Effect.gen(function* () {
const method = required(yield* integrations.get(azureID)).methods.find((method) => method.type === "external")
const field = method?.type === "external" ? method.form?.[0] : undefined
return field?.type === "string" ? field : undefined
})
const listed = required(yield* eventually(field, (field) => field?.description?.startsWith("No ") === true))
expect(listed.options).toBeUndefined()
expect(listed.custom).toBeUndefined()
}),
)
})
describe("AzurePlugin startup", () => {
it.live("starts without calling Azure, even with an expired token, then lists deployments", () =>
it.live("starts without calling Azure, then lists deployments", () =>
Effect.gen(function* () {
const cli = yield* fakeAzureCli()
const azure = yield* fakeAzure({ management: () => managementPage([managed("gpt-5-mini", "gpt-5-mini")]) })
yield* seedCatalog
yield* connect(cliCredential({ access: "expired-token", expires: Date.now() - hour }))
yield* connect(cliCredential())
yield* addPlugin(azure.endpoints).pipe(cli.provide)
expect(cli.commands).toEqual([])
@@ -329,7 +403,7 @@ describe("AzurePlugin discovery", () => {
const cli = yield* fakeAzureCli()
const azure = yield* fakeAzure({
resource: (request) =>
request.authorization === "Bearer stored-token"
request.authorization === "Bearer https://cognitiveservices.azure.com/.default-token"
? resourceList(listed("gpt-5-mini", "gpt-5-mini"))
: unauthorized(),
})
@@ -1011,9 +1011,9 @@ function StringChoiceField(props: {
</text>
<Show when={row.description}>
{(description) => (
<text paddingLeft={3} fg={theme.text.muted}>
{description()}
</text>
<box paddingLeft={3}>
<text fg={theme.text.muted}>{description()}</text>
</box>
)}
</Show>
</box>