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:
@@ -1,3 +1,4 @@
|
|||||||
|
import { APIError } from '@anthropic-ai/sdk'
|
||||||
import type {
|
import type {
|
||||||
ResolvedCodexCredentials,
|
ResolvedCodexCredentials,
|
||||||
ResolvedProviderRequest,
|
ResolvedProviderRequest,
|
||||||
@@ -234,7 +235,10 @@ export function convertAnthropicMessagesToResponsesInput(
|
|||||||
items.push({
|
items.push({
|
||||||
type: 'function_call_output',
|
type: 'function_call_output',
|
||||||
call_id: callId,
|
call_id: callId,
|
||||||
output: convertToolResultToText(toolResult.content),
|
output: (() => {
|
||||||
|
const out = convertToolResultToText(toolResult.content)
|
||||||
|
return toolResult.is_error ? `Error: ${out}` : out
|
||||||
|
})(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +457,7 @@ function convertToolChoice(toolChoice: unknown): unknown {
|
|||||||
if (!choice?.type) return undefined
|
if (!choice?.type) return undefined
|
||||||
if (choice.type === 'auto') return 'auto'
|
if (choice.type === 'auto') return 'auto'
|
||||||
if (choice.type === 'any') return 'required'
|
if (choice.type === 'any') return 'required'
|
||||||
|
if (choice.type === 'none') return 'none'
|
||||||
if (choice.type === 'tool' && choice.name) {
|
if (choice.type === 'tool' && choice.name) {
|
||||||
return {
|
return {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
@@ -553,7 +558,13 @@ export async function performCodexRequest(options: {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorBody = await response.text().catch(() => 'unknown error')
|
const errorBody = await response.text().catch(() => 'unknown error')
|
||||||
throw new Error(`Codex API error ${response.status}: ${errorBody}`)
|
let errorResponse: object | undefined
|
||||||
|
try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ }
|
||||||
|
throw APIError.generate(
|
||||||
|
response.status, errorResponse,
|
||||||
|
`Codex API error ${response.status}: ${errorBody}`,
|
||||||
|
response.headers as unknown as Record<string, string>,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -633,11 +644,9 @@ export async function collectCodexCompletedResponse(
|
|||||||
|
|
||||||
for await (const event of readSseEvents(response)) {
|
for await (const event of readSseEvents(response)) {
|
||||||
if (event.event === 'response.failed') {
|
if (event.event === 'response.failed') {
|
||||||
throw new Error(
|
const msg = event.data?.response?.error?.message ??
|
||||||
event.data?.response?.error?.message ??
|
event.data?.error?.message ?? 'Codex response failed'
|
||||||
event.data?.error?.message ??
|
throw APIError.generate(500, undefined, msg, {} as Record<string, string>)
|
||||||
'Codex response failed',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -650,7 +659,10 @@ export async function collectCodexCompletedResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!completedResponse) {
|
if (!completedResponse) {
|
||||||
throw new Error('Codex response ended without a completed payload')
|
throw APIError.generate(
|
||||||
|
500, undefined, 'Codex response ended without a completed payload',
|
||||||
|
{} as Record<string, string>,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return completedResponse
|
return completedResponse
|
||||||
@@ -806,11 +818,9 @@ export async function* codexStreamToAnthropic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.event === 'response.failed') {
|
if (event.event === 'response.failed') {
|
||||||
throw new Error(
|
const msg = payload?.response?.error?.message ??
|
||||||
payload?.response?.error?.message ??
|
payload?.error?.message ?? 'Codex response failed'
|
||||||
payload?.error?.message ??
|
throw APIError.generate(500, undefined, msg, {} as Record<string, string>)
|
||||||
'Codex response failed',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
* OPENAI_MODEL — optional; use github:copilot or openai/gpt-4.1 style IDs
|
* 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 { isEnvTruthy } from '../../utils/envUtils.js'
|
||||||
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
|
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
type ShimCreateParams,
|
type ShimCreateParams,
|
||||||
} from './codexShim.js'
|
} from './codexShim.js'
|
||||||
import {
|
import {
|
||||||
|
isLocalProviderUrl,
|
||||||
resolveCodexApiCredentials,
|
resolveCodexApiCredentials,
|
||||||
resolveProviderRequest,
|
resolveProviderRequest,
|
||||||
} from './providerConfig.js'
|
} from './providerConfig.js'
|
||||||
@@ -213,7 +215,10 @@ function convertMessages(
|
|||||||
|
|
||||||
const assistantMsg: OpenAIMessage = {
|
const assistantMsg: OpenAIMessage = {
|
||||||
role: 'assistant',
|
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) {
|
if (toolUses.length > 0) {
|
||||||
@@ -242,7 +247,10 @@ function convertMessages(
|
|||||||
} else {
|
} else {
|
||||||
result.push({
|
result.push({
|
||||||
role: 'assistant',
|
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 (
|
if (
|
||||||
!hasEmittedFinalUsage &&
|
!hasEmittedFinalUsage &&
|
||||||
chunkUsage &&
|
chunkUsage &&
|
||||||
(chunk.choices?.length ?? 0) === 0
|
(chunk.choices?.length ?? 0) === 0 &&
|
||||||
|
lastStopReason !== null
|
||||||
) {
|
) {
|
||||||
yield {
|
yield {
|
||||||
type: 'message_delta',
|
type: 'message_delta',
|
||||||
@@ -666,9 +675,12 @@ class OpenAIShimMessages {
|
|||||||
) {
|
) {
|
||||||
const self = this
|
const self = this
|
||||||
|
|
||||||
|
let httpResponse: Response | undefined
|
||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
const request = resolveProviderRequest({ model: params.model })
|
const request = resolveProviderRequest({ model: params.model })
|
||||||
const response = await self._doRequest(request, params, options)
|
const response = await self._doRequest(request, params, options)
|
||||||
|
httpResponse = response
|
||||||
|
|
||||||
if (params.stream) {
|
if (params.stream) {
|
||||||
return new OpenAIShimStream(
|
return new OpenAIShimStream(
|
||||||
@@ -695,8 +707,9 @@ class OpenAIShimMessages {
|
|||||||
const data = await promise
|
const data = await promise
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
response: new Response(),
|
response: httpResponse ?? new Response(),
|
||||||
request_id: makeMessageId(),
|
request_id:
|
||||||
|
httpResponse?.headers.get('x-request-id') ?? makeMessageId(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,7 +787,7 @@ class OpenAIShimMessages {
|
|||||||
body.max_completion_tokens = maxCompletionTokensValue
|
body.max_completion_tokens = maxCompletionTokensValue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.stream) {
|
if (params.stream && !isLocalProviderUrl(request.baseUrl)) {
|
||||||
body.stream_options = { include_usage: true }
|
body.stream_options = { include_usage: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,12 +903,20 @@ class OpenAIShimMessages {
|
|||||||
const errorBody = await response.text().catch(() => 'unknown error')
|
const errorBody = await response.text().catch(() => 'unknown error')
|
||||||
const rateHint =
|
const rateHint =
|
||||||
isGithub && response.status === 429 ? formatRetryAfterHint(response) : ''
|
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}`,
|
`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(
|
private _convertNonStreamingResponse(
|
||||||
|
|||||||
Reference in New Issue
Block a user