feat(llm): resolve Azure provider natively
This commit is contained in:
@@ -9,6 +9,7 @@ export interface ProviderResolution {
|
||||
readonly protocol: Protocol
|
||||
readonly baseURL?: string
|
||||
readonly auth: ProviderAuth
|
||||
readonly queryParams?: Record<string, string>
|
||||
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
|
||||
|
||||
@@ -3,10 +3,25 @@ import { ProviderID } from "../schema"
|
||||
|
||||
export const id = ProviderID.make("azure")
|
||||
|
||||
const stringOption = (options: Record<string, unknown>, key: string) => {
|
||||
const value = options[key]
|
||||
if (typeof value === "string" && value.trim() !== "") return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
const baseURL = (options: Record<string, unknown>) => {
|
||||
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"
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -21,20 +21,12 @@ export type ProviderFamilyModelInput = Omit<OpenAICompatibleChatModelInput, "pro
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
|
||||
const isStringRecord = (value: unknown): value is Record<string, string> =>
|
||||
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) =>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -119,6 +119,22 @@ export const mediaBytes = (part: MediaPart) =>
|
||||
|
||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||
|
||||
const isStringRecord = (value: unknown): value is Record<string, string> =>
|
||||
isRecord(value) && Object.values(value).every((item) => typeof item === "string")
|
||||
|
||||
export const queryParams = (request: { readonly model: { readonly native?: Record<string, unknown> } }) => {
|
||||
const value = request.model.native?.queryParams
|
||||
if (!isStringRecord(value)) return undefined
|
||||
return value
|
||||
}
|
||||
|
||||
export const withQuery = (url: string, params: Record<string, string> | 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)
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AmazonBedrock,
|
||||
Anthropic,
|
||||
Azure,
|
||||
GitHubCopilot,
|
||||
Google,
|
||||
LLM,
|
||||
@@ -27,6 +28,7 @@ type Input = {
|
||||
const PROVIDERS: Record<string, ProviderResolverShape> = {
|
||||
"@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 } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user