fix(ai): omp commit fails with HTTP 400 on OpenAI-compatible endpoints when reasoning enabled (#577)

* fix(ai): Added developer role support for reasoning-enabled OpenAI models

- Added supportsDeveloperRole function to check developer role support based on provider and base URL.
- Updated system prompt role logic to use developer role only when supported.
- Improved reasoning content handling with provider-specific role enforcement.

* feat(ai): Added developer role support for reasoning models with provider fallback

- Exported supportsDeveloperRole function to enable cross-provider usage.
- Updated Azure provider to check developer role support before assignment.
- Implemented system role fallback when developer role is unsupported.
- Fixed role assignment to use developer role when supported.

* feat(ai): added support for Azure OpenAI inference endpoint

- Allow Azure OpenAI Inference endpoint (azure.com/openai) via `includes()` matching on baseUrl, enabling broader Azure-compatibility.

* test: add test file for developer role handler in OpenAI responses provider

* fix(ai): fixed developer role validation to reject custom base URLs

- Changed supportsDeveloperRole to use base URL pattern matching instead of provider short-circuit.
- Added tests to verify developer role support returns false for custom proxy base URLs.

* fix(ai): Updated GitHub Copilot endpoint detection

- Expands GitHub Copilot endpoint detection to support multiple GitHub Copilot enterprise and copilot-api domains.
- Adds 3 new test scenarios wrapping unsupported/correct patterns for github-copilot provider endpoints.

* chore(ci): Add *.tmp pattern in OS section

- Adds *.tmp to ignore all temporary files with .tmp extension
- Groups with other OS temp files (.DS_Store)

* docs(ai): Consolidate changelog entry for developer role fix

* feat(ai/providers): Added baseUrl resolution support across OpenAI providers

- Resolve baseUrl before buildParams calls in azure-openai-responses.ts for correct system role logic.
- Add baseUrl resolution to openai-responses.ts and openai-completions.ts.
- Add optional resolvedBaseUrl to getCompat function for better compatibility detection.
- Update supportsDeveloperRole to accept both Model objects and baseUrl strings.
This commit is contained in:
David Dembeck
2026-03-31 21:41:29 -05:00
committed by GitHub
parent 862593d736
commit eaf28b341d
7 changed files with 131 additions and 15 deletions
+1
View File
@@ -25,6 +25,7 @@ target/
*~
# OS
*.tmp
.DS_Store
# Logs & profiling
+2
View File
@@ -2,6 +2,8 @@
## [Unreleased]
### Fixed
- Fixed `omp commit` failing with HTTP 400 errors when using reasoning-enabled models on OpenAI-compatible endpoints that don't support the `developer` role (e.g., GitHub Copilot, custom proxies). Now falls back to `system` role when `developer` is unsupported.
## [13.17.0] - 2026-03-30
### Changed
@@ -22,6 +22,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
import { supportsDeveloperRole } from "./openai-responses";
import {
appendResponsesToolResultMessages,
convertResponsesAssistantMessage,
@@ -113,7 +114,8 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
// Create Azure OpenAI client
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const client = createClient(model, apiKey, options);
const params = buildParams(model, context, options, deploymentName);
const { baseUrl } = resolveAzureConfig(model, options);
const params = buildParams(model, context, options, deploymentName, baseUrl);
const requestAbortController = new AbortController();
const requestSignal = options?.signal
? AbortSignal.any([options.signal, requestAbortController.signal])
@@ -124,7 +126,7 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
api: output.api,
model: model.id,
method: "POST",
url: `${resolveAzureConfig(model, options).baseUrl}/responses`,
url: `${baseUrl}/responses`,
body: params,
};
const openaiStream = await client.responses.create(params, { signal: requestSignal });
@@ -245,8 +247,9 @@ function buildParams(
context: Context,
options: AzureOpenAIResponsesOptions | undefined,
deploymentName: string,
resolvedBaseUrl?: string,
) {
const messages = convertMessages(model, context, true);
const messages = convertMessages(model, context, true, resolvedBaseUrl);
const params: AzureOpenAIResponsesSamplingParams = {
model: deploymentName,
@@ -323,13 +326,14 @@ function convertMessages(
model: Model<"azure-openai-responses">,
context: Context,
strictResponsesPairing: boolean,
resolvedBaseUrl?: string,
): ResponseInput {
const messages: ResponseInput = [];
const transformedMessages = transformMessages(context.messages, model, normalizeResponsesToolCallIdForTransform);
const knownCallIds = new Set<string>();
if (context.systemPrompt) {
const role = model.reasoning ? "developer" : "system";
const role = model.reasoning && supportsDeveloperRole(resolvedBaseUrl ?? model) ? "developer" : "system";
messages.push({
role,
content: context.systemPrompt.toWellFormed(),
@@ -36,10 +36,14 @@ function detectStrictModeSupport(provider: string, baseUrl: string): boolean {
/**
* Detect compatibility settings from provider and baseUrl for known providers.
* Provider takes precedence over URL-based detection since it's explicitly configured.
* @param model - The model configuration
* @param resolvedBaseUrl - Optional resolved base URL (e.g., after GitHub Copilot proxy-ep resolution).
* If provided, this takes precedence over model.baseUrl for URL-based checks.
*/
export function detectOpenAICompat(model: Model<"openai-completions">): ResolvedOpenAICompat {
export function detectOpenAICompat(model: Model<"openai-completions">, resolvedBaseUrl?: string): ResolvedOpenAICompat {
const provider = model.provider;
const baseUrl = model.baseUrl;
// Use resolvedBaseUrl if provided (e.g., after GitHub Copilot proxy-ep resolution)
const baseUrl = resolvedBaseUrl ?? model.baseUrl;
const isCerebras = provider === "cerebras" || baseUrl.includes("cerebras.ai");
const isZai = provider === "zai" || baseUrl.includes("api.z.ai");
@@ -109,9 +113,15 @@ export function detectOpenAICompat(model: Model<"openai-completions">): Resolved
/**
* Resolve compatibility settings by layering explicit model.compat overrides onto
* the detected defaults. This is the canonical compat view for both metadata and transport.
* @param model - The model configuration
* @param resolvedBaseUrl - Optional resolved base URL (e.g., after GitHub Copilot proxy-ep resolution).
* If provided, this takes precedence over model.baseUrl for URL-based checks.
*/
export function resolveOpenAICompat(model: Model<"openai-completions">): ResolvedOpenAICompat {
const detected = detectOpenAICompat(model);
export function resolveOpenAICompat(
model: Model<"openai-completions">,
resolvedBaseUrl?: string,
): ResolvedOpenAICompat {
const detected = detectOpenAICompat(model, resolvedBaseUrl);
if (!model.compat) {
return detected;
}
@@ -199,7 +199,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
options?.headers,
options?.initiatorOverride,
);
const params = buildParams(model, context, options);
const params = buildParams(model, context, options, baseUrl);
options?.onPayload?.(params);
rawRequestDump = {
provider: model.provider,
@@ -554,8 +554,13 @@ async function createClient(
};
}
function buildParams(model: Model<"openai-completions">, context: Context, options?: OpenAICompletionsOptions) {
const compat = getCompat(model);
function buildParams(
model: Model<"openai-completions">,
context: Context,
options: OpenAICompletionsOptions | undefined,
resolvedBaseUrl?: string,
) {
const compat = getCompat(model, resolvedBaseUrl);
const messages = convertMessages(model, context, compat);
maybeAddOpenRouterAnthropicCacheControl(model, messages);
@@ -1118,7 +1123,9 @@ export function detectCompat(model: Model<"openai-completions">): ResolvedOpenAI
/**
* Get resolved compatibility settings for a model.
* Uses explicit model.compat if provided, otherwise auto-detects from provider/URL.
* @param model - The model configuration
* @param resolvedBaseUrl - Optional resolved base URL (e.g., after GitHub Copilot proxy-ep resolution).
*/
function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompat {
return resolveOpenAICompat(model);
function getCompat(model: Model<"openai-completions">, resolvedBaseUrl?: string): ResolvedOpenAICompat {
return resolveOpenAICompat(model, resolvedBaseUrl);
}
+16 -2
View File
@@ -167,7 +167,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
options?.initiatorOverride,
);
const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
const { params } = buildParams(model, context, options, providerSessionState);
const { params } = buildParams(model, context, options, providerSessionState, baseUrl);
const requestAbortController = new AbortController();
const requestSignal = options?.signal
? AbortSignal.any([options.signal, requestAbortController.signal])
@@ -290,6 +290,7 @@ function buildParams(
context: Context,
options: OpenAIResponsesOptions | undefined,
providerSessionState: OpenAIResponsesProviderSessionState | undefined,
resolvedBaseUrl?: string,
): { conversationMessages: ResponseInput; params: OpenAIResponsesSamplingParams } {
const strictResponsesPairing =
options?.strictResponsesPairing ??
@@ -303,7 +304,7 @@ function buildParams(
const messages: ResponseInput = [...conversationMessages];
if (context.systemPrompt) {
const role = model.reasoning ? "developer" : "system";
const role = model.reasoning && supportsDeveloperRole(resolvedBaseUrl ?? model) ? "developer" : "system";
messages.unshift({
role,
content: context.systemPrompt.toWellFormed(),
@@ -398,6 +399,19 @@ function supportsStrictMode(model: Model<"openai-responses">): boolean {
);
}
export function supportsDeveloperRole(modelOrBaseUrl: Pick<Model, "provider" | "baseUrl"> | string): boolean {
const baseUrl =
typeof modelOrBaseUrl === "string" ? modelOrBaseUrl.toLowerCase() : (modelOrBaseUrl.baseUrl ?? "").toLowerCase();
return (
baseUrl.includes("api.openai.com") ||
baseUrl.includes(".openai.azure.com") ||
baseUrl.includes("azure.com/openai") ||
baseUrl.includes("models.inference.ai.azure.com") ||
baseUrl.includes("githubcopilot.com") ||
baseUrl.includes("copilot-api.")
);
}
function convertConversationMessages(
model: Model<"openai-responses">,
context: Context,
@@ -0,0 +1,78 @@
import { describe, expect, it } from "bun:test";
import { supportsDeveloperRole } from "../src/providers/openai-responses";
import type { Model } from "../src/types";
describe("supportsDeveloperRole", () => {
it("returns true for openai provider with official API base URL", () => {
const model = { provider: "openai", baseUrl: "https://api.openai.com/v1" } as Model;
expect(supportsDeveloperRole(model)).toBe(true);
});
it("returns false for openai provider with custom proxy base URL", () => {
const model = { provider: "openai", baseUrl: "https://my-proxy.example.com/v1" } as Model;
expect(supportsDeveloperRole(model)).toBe(false);
});
it("returns true for github-copilot provider", () => {
const model = { provider: "github-copilot", baseUrl: "https://api.githubcopilot.com" } as Model;
expect(supportsDeveloperRole(model)).toBe(true);
});
it("returns false for github-copilot provider with custom proxy base URL", () => {
const model = { provider: "github-copilot", baseUrl: "https://proxy.example.com/v1" } as Model;
expect(supportsDeveloperRole(model)).toBe(false);
});
it("returns true for Azure OpenAI base URL", () => {
const model = { provider: "azure-openai", baseUrl: "https://my-resource.openai.azure.com/openai" } as Model;
expect(supportsDeveloperRole(model)).toBe(true);
});
it("returns true for Azure AI Inference base URL", () => {
const model = {
provider: "azure-openai",
baseUrl: "https://models.inference.ai.azure.com/v1/chat/completions",
} as Model;
expect(supportsDeveloperRole(model)).toBe(true);
});
it("returns true for api.openai.com base URL", () => {
const model = { provider: "custom", baseUrl: "https://api.openai.com/v1/chat/completions" } as Model;
expect(supportsDeveloperRole(model)).toBe(true);
});
it("returns false for generic third-party provider", () => {
const model = { provider: "custom", baseUrl: "https://api.example.com/v1" } as Model;
expect(supportsDeveloperRole(model)).toBe(false);
});
it("returns false for local/localhost endpoints", () => {
const model = { provider: "custom", baseUrl: "http://localhost:8080/v1" } as Model;
expect(supportsDeveloperRole(model)).toBe(false);
});
it("is case-insensitive for base URL matching", () => {
const model = { provider: "custom", baseUrl: "https://API.OPENAI.COM/v1" } as Model;
expect(supportsDeveloperRole(model)).toBe(true);
});
it("returns true for azure.com/openai base URL", () => {
const model = { provider: "custom", baseUrl: "https://azure.com/openai/deployments/my-model" } as Model;
expect(supportsDeveloperRole(model)).toBe(true);
});
it("returns true for github-copilot provider with api.individual.githubcopilot.com", () => {
const model = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com" } as Model;
expect(supportsDeveloperRole(model)).toBe(true);
});
it("returns true for github-copilot provider with api.enterprise.githubcopilot.com", () => {
const model = { provider: "github-copilot", baseUrl: "https://api.enterprise.githubcopilot.com" } as Model;
expect(supportsDeveloperRole(model)).toBe(true);
});
it("returns true for github-copilot provider with copilot-api enterprise domain", () => {
const model = { provider: "github-copilot", baseUrl: "https://copilot-api.mycompany.com" } as Model;
expect(supportsDeveloperRole(model)).toBe(true);
});
});