diff --git a/packages/llm/src/auth.ts b/packages/llm/src/auth.ts index 5b3474abfe..3235f9d8dd 100644 --- a/packages/llm/src/auth.ts +++ b/packages/llm/src/auth.ts @@ -10,7 +10,8 @@ import type { LLMError, LLMRequest } from "./schema" * Most adapters use the default `Auth.bearer`, which reads * `request.model.apiKey` and sets `Authorization: Bearer ...`. Providers * that use a different header pick `Auth.apiKeyHeader(name)` (e.g. - * Anthropic's `x-api-key`, Gemini's `x-goog-api-key`). + * Anthropic's `x-api-key`, Gemini's `x-goog-api-key`) or a provider-aware + * helper such as `Auth.openAI` for Azure OpenAI's static `api-key` header. * * Adapters that need per-request signing (AWS SigV4, future Vertex IAM, * future Azure AAD) implement `Auth` as a function that hashes the body, @@ -52,6 +53,22 @@ const fromApiKey = (from: (apiKey: string) => Record): Auth => ( */ export const bearer: Auth = fromApiKey((key) => ({ authorization: `Bearer ${key}` })) +/** + * OpenAI-compatible auth with Azure OpenAI's static API-key exception. Azure + * Entra/OAuth callers can still pre-set `authorization` and omit `apiKey`. + */ +export const openAI: Auth = ({ request, headers }) => { + const key = request.model.apiKey + if (!key) return Effect.succeed(headers) + if (request.model.provider === "azure") { + return Effect.succeed({ + ...Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "authorization")), + "api-key": key, + }) + } + return Effect.succeed({ ...headers, authorization: `Bearer ${key}` }) +} + /** * Set a custom header to `request.model.apiKey`. No-op when `model.apiKey` * is unset. Used by Anthropic (`x-api-key`) and Gemini (`x-goog-api-key`). diff --git a/packages/llm/src/provider/openai-chat.ts b/packages/llm/src/provider/openai-chat.ts index aca8c284f2..772bcdb6e4 100644 --- a/packages/llm/src/provider/openai-chat.ts +++ b/packages/llm/src/provider/openai-chat.ts @@ -1,5 +1,6 @@ import { Effect, Schema } from "effect" import { Adapter } from "../adapter" +import { Auth } from "../auth" import { Endpoint } from "../endpoint" import { Framing } from "../framing" import { capabilities, model as llmModel, type ModelInput } from "../llm" @@ -355,6 +356,7 @@ export const adapter = Adapter.fromProtocol({ id: ADAPTER, protocol, endpoint: Endpoint.baseURL({ default: "https://api.openai.com/v1", path: "/chat/completions" }), + auth: Auth.openAI, framing: Framing.sse, }) diff --git a/packages/llm/src/provider/openai-responses.ts b/packages/llm/src/provider/openai-responses.ts index 1dd8474b4d..37d29a27bf 100644 --- a/packages/llm/src/provider/openai-responses.ts +++ b/packages/llm/src/provider/openai-responses.ts @@ -1,5 +1,6 @@ import { Effect, Schema } from "effect" import { Adapter } from "../adapter" +import { Auth } from "../auth" import { Endpoint } from "../endpoint" import { Framing } from "../framing" import { capabilities, model as llmModel, type ModelInput } from "../llm" @@ -385,6 +386,7 @@ export const adapter = Adapter.fromProtocol({ id: ADAPTER, protocol, endpoint: Endpoint.baseURL({ default: "https://api.openai.com/v1", path: "/responses" }), + auth: Auth.openAI, framing: Framing.sse, }) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 89e0c3486d..5a05508a74 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -83,6 +83,37 @@ describe("OpenAI Chat adapter", () => { }), ) + it.effect("uses Azure api-key header for static OpenAI Chat keys", () => + Effect.gen(function* () { + yield* LLMClient.make({ adapters: [OpenAIChat.adapter] }) + .generate( + LLM.updateRequest(request, { + model: LLM.model({ + ...model, + provider: "azure", + baseURL: "https://opencode-test.openai.azure.com/openai/v1/", + apiKey: "azure-key", + headers: { authorization: "Bearer stale" }, + }), + }), + ) + .pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("api-key")).toBe("azure-key") + expect(web.headers.get("authorization")).toBeNull() + 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 255f622d62..b697d267c5 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -62,6 +62,37 @@ describe("OpenAI Responses adapter", () => { }), ) + it.effect("uses Azure api-key header for static OpenAI Responses keys", () => + Effect.gen(function* () { + yield* LLMClient.make({ adapters: [OpenAIResponses.adapter] }) + .generate( + LLM.updateRequest(request, { + model: LLM.model({ + ...model, + provider: "azure", + baseURL: "https://opencode-test.openai.azure.com/openai/v1/", + apiKey: "azure-key", + headers: { authorization: "Bearer stale" }, + }), + }), + ) + .pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("api-key")).toBe("azure-key") + expect(web.headers.get("authorization")).toBeNull() + 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(