fix: shim reliability and protocol compliance overhaul

Addresses the most critical remaining issues in the provider shim layer,
building on top of #124 (recursive schema normalization + try/finally).

openaiShim.ts:
- Throw APIError via SDK factory instead of plain Error — enables retry
  on 429/503 (was completely broken: zero retries for all 3P providers)
- Guard stop_reason !== null before emitting usage-only message_delta
  (Azure/Groq send usage before finish_reason)
- Fix assistant content: join text parts instead of invalid as-string cast
  (Mistral rejects array content on assistant role)
- Expose real HTTP Response in withResponse() for header inspection
- Skip stream_options for local providers (Ollama < 0.5 compatibility)

codexShim.ts:
- Throw APIError at all 4 throw sites (HTTP + 3 streaming errors)
- Add tool_choice 'none' mapping (was silently ignored)
- Forward is_error flag with Error: prefix (matching openaiShim)
This commit is contained in:
Juan Camilo
2026-04-02 14:41:40 +02:00
parent 3d72d9e5e2
commit f4818dc213
2 changed files with 52 additions and 21 deletions

View File

@@ -21,6 +21,7 @@
* OPENAI_MODEL — optional; use github:copilot or openai/gpt-4.1 style IDs
*/
import { APIError } from '@anthropic-ai/sdk'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
import {
@@ -33,6 +34,7 @@ import {
type ShimCreateParams,
} from './codexShim.js'
import {
isLocalProviderUrl,
resolveCodexApiCredentials,
resolveProviderRequest,
} from './providerConfig.js'
@@ -213,7 +215,10 @@ function convertMessages(
const assistantMsg: OpenAIMessage = {
role: 'assistant',
content: convertContentBlocks(textContent) as string,
content: (() => {
const c = convertContentBlocks(textContent)
return typeof c === 'string' ? c : Array.isArray(c) ? c.map((p: { text?: string }) => p.text ?? '').join('') : ''
})(),
}
if (toolUses.length > 0) {
@@ -242,7 +247,10 @@ function convertMessages(
} else {
result.push({
role: 'assistant',
content: convertContentBlocks(content) as string,
content: (() => {
const c = convertContentBlocks(content)
return typeof c === 'string' ? c : Array.isArray(c) ? c.map((p: { text?: string }) => p.text ?? '').join('') : ''
})(),
})
}
}
@@ -617,7 +625,8 @@ async function* openaiStreamToAnthropic(
if (
!hasEmittedFinalUsage &&
chunkUsage &&
(chunk.choices?.length ?? 0) === 0
(chunk.choices?.length ?? 0) === 0 &&
lastStopReason !== null
) {
yield {
type: 'message_delta',
@@ -666,9 +675,12 @@ class OpenAIShimMessages {
) {
const self = this
let httpResponse: Response | undefined
const promise = (async () => {
const request = resolveProviderRequest({ model: params.model })
const response = await self._doRequest(request, params, options)
httpResponse = response
if (params.stream) {
return new OpenAIShimStream(
@@ -695,8 +707,9 @@ class OpenAIShimMessages {
const data = await promise
return {
data,
response: new Response(),
request_id: makeMessageId(),
response: httpResponse ?? new Response(),
request_id:
httpResponse?.headers.get('x-request-id') ?? makeMessageId(),
}
}
@@ -774,7 +787,7 @@ class OpenAIShimMessages {
body.max_completion_tokens = maxCompletionTokensValue
}
if (params.stream) {
if (params.stream && !isLocalProviderUrl(request.baseUrl)) {
body.stream_options = { include_usage: true }
}
@@ -890,12 +903,20 @@ class OpenAIShimMessages {
const errorBody = await response.text().catch(() => 'unknown error')
const rateHint =
isGithub && response.status === 429 ? formatRetryAfterHint(response) : ''
throw new Error(
let errorResponse: object | undefined
try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ }
throw APIError.generate(
response.status,
errorResponse,
`OpenAI API error ${response.status}: ${errorBody}${rateHint}`,
response.headers as unknown as Record<string, string>,
)
}
throw new Error('OpenAI shim: request loop exited unexpectedly')
throw APIError.generate(
500, undefined, 'OpenAI shim: request loop exited unexpectedly',
{} as Record<string, string>,
)
}
private _convertNonStreamingResponse(