From f2f7a338de3d5f49a2575bcfc56cb93ee3bd1ec6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 28 Apr 2026 09:27:24 -0400 Subject: [PATCH] feat(llm): resolve Azure provider natively --- packages/llm/src/provider-resolver.ts | 3 +- packages/llm/src/provider/azure.ts | 17 ++++++++- packages/llm/src/provider/openai-chat.ts | 2 +- .../src/provider/openai-compatible-chat.ts | 16 +++------ packages/llm/src/provider/openai-responses.ts | 2 +- packages/llm/src/provider/shared.ts | 16 +++++++++ packages/llm/test/provider-resolver.test.ts | 19 +++++++++- .../llm/test/provider/openai-chat.test.ts | 21 ++++++++++- .../test/provider/openai-responses.test.ts | 23 +++++++++++- packages/opencode/src/provider/llm-bridge.ts | 3 ++ .../opencode/test/provider/llm-bridge.test.ts | 36 +++++++++++++++++-- 11 files changed, 137 insertions(+), 21 deletions(-) diff --git a/packages/llm/src/provider-resolver.ts b/packages/llm/src/provider-resolver.ts index 809b1d7ef8..88d1a03d1e 100644 --- a/packages/llm/src/provider-resolver.ts +++ b/packages/llm/src/provider-resolver.ts @@ -9,6 +9,7 @@ export interface ProviderResolution { readonly protocol: Protocol readonly baseURL?: string readonly auth: ProviderAuth + readonly queryParams?: Record readonly capabilities?: CapabilitiesInput } @@ -30,8 +31,8 @@ export const make = ( ): ProviderResolution => ({ provider: ProviderID.make(provider), protocol, - auth: options.auth ?? "bearer", ...options, + auth: options.auth ?? "bearer", }) export const define = (input: ProviderResolver): ProviderResolver => input diff --git a/packages/llm/src/provider/azure.ts b/packages/llm/src/provider/azure.ts index ce0515a411..69238d6268 100644 --- a/packages/llm/src/provider/azure.ts +++ b/packages/llm/src/provider/azure.ts @@ -3,10 +3,25 @@ import { ProviderID } from "../schema" export const id = ProviderID.make("azure") +const stringOption = (options: Record, key: string) => { + const value = options[key] + if (typeof value === "string" && value.trim() !== "") return value + return undefined +} + +const baseURL = (options: Record) => { + const resource = stringOption(options, "resourceName") + if (!resource) return undefined + return `https://${resource}.openai.azure.com/openai/v1` +} + export const resolver = ProviderResolver.define({ id, resolve: (input) => - ProviderResolver.make(id, input.options.useCompletionUrls ? "openai-chat" : "openai-responses", { auth: "bearer" }), + ProviderResolver.make(id, input.options.useCompletionUrls === true ? "openai-chat" : "openai-responses", { + baseURL: baseURL(input.options), + queryParams: { "api-version": stringOption(input.options, "apiVersion") ?? "v1" }, + }), }) export * as Azure from "./azure" diff --git a/packages/llm/src/provider/openai-chat.ts b/packages/llm/src/provider/openai-chat.ts index 9231b3488a..5709d5a107 100644 --- a/packages/llm/src/provider/openai-chat.ts +++ b/packages/llm/src/provider/openai-chat.ts @@ -245,7 +245,7 @@ const prepare = Effect.fn("OpenAIChat.prepare")(function* (request: LLMRequest) const toHttp = (target: OpenAIChatTarget, request: LLMRequest) => Effect.succeed( ProviderShared.jsonPost({ - url: `${baseUrl(request)}/chat/completions`, + url: ProviderShared.withQuery(`${baseUrl(request)}/chat/completions`, ProviderShared.queryParams(request)), body: encodeTarget(target), headers: request.model.headers, }), diff --git a/packages/llm/src/provider/openai-compatible-chat.ts b/packages/llm/src/provider/openai-compatible-chat.ts index 46e14d8946..2720e88c18 100644 --- a/packages/llm/src/provider/openai-compatible-chat.ts +++ b/packages/llm/src/provider/openai-compatible-chat.ts @@ -21,20 +21,12 @@ export type ProviderFamilyModelInput = Omit => - typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string") - -const queryParams = (request: LLMRequest) => { - const value = request.model.native?.queryParams - if (!isStringRecord(value)) return undefined - return value -} - const completionUrl = (request: LLMRequest) => { if (!request.model.baseURL) return undefined - const url = new URL(`${ProviderShared.trimBaseUrl(request.model.baseURL)}/chat/completions`) - for (const [key, value] of Object.entries(queryParams(request) ?? {})) url.searchParams.set(key, value) - return url.toString() + return ProviderShared.withQuery( + `${ProviderShared.trimBaseUrl(request.model.baseURL)}/chat/completions`, + ProviderShared.queryParams(request), + ) } const toHttp = (target: OpenAIChatTarget, request: LLMRequest) => diff --git a/packages/llm/src/provider/openai-responses.ts b/packages/llm/src/provider/openai-responses.ts index 68e82e5c3c..f8399e049c 100644 --- a/packages/llm/src/provider/openai-responses.ts +++ b/packages/llm/src/provider/openai-responses.ts @@ -223,7 +223,7 @@ const prepare = Effect.fn("OpenAIResponses.prepare")(function* (request: LLMRequ const toHttp = (target: OpenAIResponsesTarget, request: LLMRequest) => Effect.succeed( ProviderShared.jsonPost({ - url: `${baseUrl(request)}/responses`, + url: ProviderShared.withQuery(`${baseUrl(request)}/responses`, ProviderShared.queryParams(request)), body: encodeTarget(target), headers: request.model.headers, }), diff --git a/packages/llm/src/provider/shared.ts b/packages/llm/src/provider/shared.ts index a3ef3cd6f4..b51102175d 100644 --- a/packages/llm/src/provider/shared.ts +++ b/packages/llm/src/provider/shared.ts @@ -119,6 +119,22 @@ export const mediaBytes = (part: MediaPart) => export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "") +const isStringRecord = (value: unknown): value is Record => + isRecord(value) && Object.values(value).every((item) => typeof item === "string") + +export const queryParams = (request: { readonly model: { readonly native?: Record } }) => { + const value = request.model.native?.queryParams + if (!isStringRecord(value)) return undefined + return value +} + +export const withQuery = (url: string, params: Record | undefined) => { + if (!params) return url + const result = new URL(url) + for (const [key, value] of Object.entries(params)) result.searchParams.set(key, value) + return result.toString() +} + export const toolResultText = (part: ToolResultPart) => { if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) return encodeJson(part.result.value) diff --git a/packages/llm/test/provider-resolver.test.ts b/packages/llm/test/provider-resolver.test.ts index 3203f2771b..d64566854b 100644 --- a/packages/llm/test/provider-resolver.test.ts +++ b/packages/llm/test/provider-resolver.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { GitHubCopilot, OpenAI, OpenAICompatibleFamily, ProviderResolver } from "../src" +import { Azure, GitHubCopilot, OpenAI, OpenAICompatibleFamily, ProviderResolver } from "../src" describe("provider resolver", () => { test("fixed providers resolve protocol and auth defaults", () => { @@ -31,4 +31,21 @@ describe("provider resolver", () => { auth: "bearer", }) }) + + test("Azure resolves resource URLs and API-version query params", () => { + expect( + Azure.resolver.resolve( + ProviderResolver.input("gpt-5", "azure", { resourceName: "opencode-test", apiVersion: "2025-04-01-preview" }), + ), + ).toMatchObject({ + provider: "azure", + protocol: "openai-responses", + baseURL: "https://opencode-test.openai.azure.com/openai/v1", + queryParams: { "api-version": "2025-04-01-preview" }, + }) + expect(Azure.resolver.resolve(ProviderResolver.input("gpt-4.1", "azure", { useCompletionUrls: true }))).toMatchObject({ + protocol: "openai-chat", + queryParams: { "api-version": "v1" }, + }) + }) }) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index f836ca5d8f..1a55163b33 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -1,10 +1,11 @@ import { describe, expect } from "bun:test" import { Effect, Layer, Schema, Stream } from "effect" +import { HttpClientRequest } from "effect/unstable/http" import { LLM, ProviderRequestError } from "../../src" import { LLMClient } from "../../src/adapter" import { OpenAIChat } from "../../src/provider/openai-chat" import { testEffect } from "../lib/effect" -import { fixedResponse, truncatedStream } from "../lib/http" +import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http" import { sseEvents } from "../lib/sse" const TargetJson = Schema.fromJsonString(Schema.Unknown) @@ -60,6 +61,24 @@ describe("OpenAI Chat adapter", () => { }), ) + it.effect("adds native query params to the Chat Completions URL", () => + Effect.gen(function* () { + yield* LLMClient.make({ adapters: [OpenAIChat.adapter] }) + .generate(LLM.updateRequest(request, { model: LLM.model({ ...model, native: { queryParams: { "api-version": "v1" } } }) })) + .pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/chat/completions?api-version=v1") + return input.respond(sseEvents(deltaChunk({}, "stop")), { headers: { "content-type": "text/event-stream" } }) + }), + ), + ), + ) + }), + ) + it.effect("prepares assistant tool-call and tool-result messages", () => Effect.gen(function* () { const prepared = yield* LLMClient.make({ adapters: [OpenAIChat.adapter] }).prepare( diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index e9d4394ee9..3a076a972a 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -1,10 +1,11 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { HttpClientRequest } from "effect/unstable/http" import { LLM, ProviderRequestError } from "../../src" import { LLMClient } from "../../src/adapter" import { OpenAIResponses } from "../../src/provider/openai-responses" import { testEffect } from "../lib/effect" -import { fixedResponse } from "../lib/http" +import { dynamicResponse, fixedResponse } from "../lib/http" import { sseEvents } from "../lib/sse" const model = OpenAIResponses.model({ @@ -41,6 +42,26 @@ describe("OpenAI Responses adapter", () => { }), ) + it.effect("adds native query params to the Responses URL", () => + Effect.gen(function* () { + yield* LLMClient.make({ adapters: [OpenAIResponses.adapter] }) + .generate(LLM.updateRequest(request, { model: LLM.model({ ...model, native: { queryParams: { "api-version": "v1" } } }) })) + .pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/responses?api-version=v1") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + }), + ) + it.effect("prepares function call and function output input items", () => Effect.gen(function* () { const prepared = yield* LLMClient.make({ adapters: [OpenAIResponses.adapter] }).prepare( diff --git a/packages/opencode/src/provider/llm-bridge.ts b/packages/opencode/src/provider/llm-bridge.ts index 63501050c4..0c8556eaf3 100644 --- a/packages/opencode/src/provider/llm-bridge.ts +++ b/packages/opencode/src/provider/llm-bridge.ts @@ -1,6 +1,7 @@ import { AmazonBedrock, Anthropic, + Azure, GitHubCopilot, Google, LLM, @@ -27,6 +28,7 @@ type Input = { const PROVIDERS: Record = { "@ai-sdk/amazon-bedrock": AmazonBedrock.resolver, "@ai-sdk/anthropic": Anthropic.resolver, + "@ai-sdk/azure": Azure.resolver, "@ai-sdk/baseten": OpenAICompatibleFamily.resolver, "@ai-sdk/cerebras": OpenAICompatibleFamily.resolver, "@ai-sdk/deepinfra": OpenAICompatibleFamily.resolver, @@ -144,6 +146,7 @@ export const toModelRef = (input: Input): ModelRef | undefined => { opencodeProviderID: input.provider.id, opencodeModelID: input.model.id, npm: input.model.api.npm, + ...(resolution.queryParams ? { queryParams: resolution.queryParams } : {}), }, }) } diff --git a/packages/opencode/test/provider/llm-bridge.test.ts b/packages/opencode/test/provider/llm-bridge.test.ts index 63eb89d435..ecaf7fc2d0 100644 --- a/packages/opencode/test/provider/llm-bridge.test.ts +++ b/packages/opencode/test/provider/llm-bridge.test.ts @@ -106,7 +106,7 @@ describe("ProviderLLMBridge", () => { }) }) - test("maps GitHub Copilot through its provider route", () => { + test("maps GitHub Copilot through its provider resolver", () => { const ref = ProviderLLMBridge.toModelRef({ provider: provider({ id: ProviderID.make("github-copilot"), key: "copilot-key" }), model: model({ id: "gpt-5", providerID: "github-copilot", npm: "@ai-sdk/github-copilot" }), @@ -119,6 +119,39 @@ describe("ProviderLLMBridge", () => { }) }) + test("maps Azure to Responses with resource URL and api-version query", () => { + const ref = ProviderLLMBridge.toModelRef({ + provider: provider({ + id: ProviderID.make("azure"), + key: "azure-key", + options: { resourceName: "opencode-test", apiVersion: "2025-04-01-preview" }, + }), + model: model({ id: "gpt-5", providerID: "azure", npm: "@ai-sdk/azure" }), + }) + + expect(ref).toMatchObject({ + provider: "azure", + protocol: "openai-responses", + baseURL: "https://opencode-test.openai.azure.com/openai/v1", + headers: { authorization: "Bearer azure-key" }, + native: { queryParams: { "api-version": "2025-04-01-preview" } }, + }) + }) + + test("maps Azure completion URL opt-in to Chat Completions", () => { + const ref = ProviderLLMBridge.toModelRef({ + provider: provider({ id: ProviderID.make("azure"), key: "azure-key", options: { resourceName: "opencode-test" } }), + model: model({ id: "gpt-4.1", providerID: "azure", npm: "@ai-sdk/azure", options: { useCompletionUrls: true } }), + }) + + expect(ref).toMatchObject({ + provider: "azure", + protocol: "openai-chat", + baseURL: "https://opencode-test.openai.azure.com/openai/v1", + native: { queryParams: { "api-version": "v1" } }, + }) + }) + test("keeps provider and model overrides ahead of defaults", () => { const ref = ProviderLLMBridge.toModelRef({ provider: provider({ @@ -170,7 +203,6 @@ describe("ProviderLLMBridge", () => { test("leaves undecided provider packages unmapped", () => { const unsupported = [ ["mistral", "mistral-large", "@ai-sdk/mistral"], - ["azure", "gpt-4.1", "@ai-sdk/azure"], ] as const expect(