Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ca91e20
fix(api): self-heal `tool_stream` rejection from non-Z.AI gateways (#…
jatmn Jul 13, 2026
44a7c51
fix(shim): scope tool stream retry budget
jatmn Jul 13, 2026
7063e3b
fix(api): clarify tool stream fallback error
jatmn Jul 13, 2026
7d0bc3c
fix(api): prioritize tool stream rejection
jatmn Jul 13, 2026
c460b31
fix(api): match unrecognized tool_stream arguments
jatmn Jul 13, 2026
0fd2669
fix(api): harden tool stream self-heal
jatmn Jul 14, 2026
03ab777
fix(api): accept quoted tool stream errors
jatmn Jul 14, 2026
f6dce9b
fix(api): match tool stream parameter variants
jatmn Jul 14, 2026
c4ad824
fix(api): recognize structured tool stream errors
jatmn Jul 14, 2026
cf3e8e9
fix(api): avoid tool stream name false positives
jatmn Jul 14, 2026
80991e5
fix(api): classify gateway tool stream rejections
jatmn Jul 14, 2026
f0d2c02
fix(api): exclude tool name validation errors
jatmn Jul 14, 2026
1af91ff
fix(api): handle structured tool stream validation
jatmn Jul 14, 2026
e2b2b9d
fix(api): harden tool stream error classification
jatmn Jul 15, 2026
c2237e5
fix(api): harden tool stream error classification
jatmn Jul 15, 2026
0996ea9
fix(api): narrow tool stream recovery classifier
jatmn Jul 15, 2026
afecece
fix(api): support FastAPI tool stream errors
jatmn Jul 15, 2026
9e63d02
fix(api): accept wrapped tool stream errors
jatmn Jul 15, 2026
7bdc2c4
fix(api): harden tool stream recovery classifier
jatmn Jul 15, 2026
45c7378
fix(api): harden tool stream recovery classifier
jatmn Jul 15, 2026
6591989
fix(api): distinguish tool stream schema diagnostics
jatmn Jul 15, 2026
f43589a
fix(api): handle structured tool stream errors
jatmn Jul 15, 2026
8ecbdb2
fix(api): avoid tool stream schema false positives
jatmn Jul 15, 2026
f589bc9
test(api): isolate watchdog client mock
jatmn Jul 15, 2026
ce708fc
test(api): restore watchdog client mock
jatmn Jul 15, 2026
47bc52b
test(api): reset watchdog module mock
jatmn Jul 15, 2026
b3df09e
test(api): scope watchdog client stub
jatmn Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/integrations/runtimeMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,29 @@ describe('resolveOpenAIShimRuntimeContext - GLM on a non-Z.AI gateway (#1896)',
expect(result.openaiShimConfig.preserveReasoningContent).toBe(true)
// tool_stream is Z.AI-proprietary and must NOT be inferred; NVIDIA NIM (and
// other third-party gateways) reject it with 400 Unsupported parameter(s).
expect(result.openaiShimConfig.enableToolStreaming).not.toBe(true)
expect(result.openaiShimConfig.enableToolStreaming).toBe(false)
})
})

describe('resolveOpenAIShimRuntimeContext - NVIDIA NIM GLM-5.2 (regression #1950)', () => {
// The user selected `z-ai/glm-5.2` from NVIDIA NIM's discovered (dynamic)
// model catalog. Even when a GLM catalog entry exists on a non-Z.AI gateway,
// `tool_stream` must stay off (Z.AI-proprietary); the reasoning-shaping shim
// still applies because GLM needs it on any gateway.
it('does not enable tool_stream for NVIDIA NIM GLM-5.2 and keeps the reasoning shim', () => {
const result = resolveOpenAIShimRuntimeContext({
model: 'z-ai/glm-5.2',
baseUrl: 'https://integrate.api.nvidia.com/v1',
processEnv: { NVIDIA_NIM: '1' },
})

expect(result.routeId).toBe('nvidia-nim')
expect(result.openaiShimConfig.enableToolStreaming).toBe(false)
expect(result.openaiShimConfig.thinkingRequestFormat).toBe('zai-compatible')
expect(result.openaiShimConfig.preserveReasoningContent).toBe(true)
expect(result.openaiShimConfig.requireReasoningContentOnAssistantMessages).toBe(true)
expect(result.openaiShimConfig.maxTokensField).toBe('max_tokens')
expect(result.openaiShimConfig.removeBodyFields).toContain('store')
})
})

Expand Down
41 changes: 21 additions & 20 deletions src/services/api/claude.streamWatchdog.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from 'bun:test'
import type {
Expand Down Expand Up @@ -51,22 +50,25 @@ type CreateHandler = (...args: CreateArgs) => unknown

let createHandler: CreateHandler | undefined
let importCounter = 0

mock.module('./client.js', () => ({
CLIENT_REQUEST_ID_HEADER: actualClientModule.CLIENT_REQUEST_ID_HEADER,
getAnthropicClient: async () => ({
beta: {
messages: {
create: (...args: CreateArgs) => {
if (!createHandler) {
throw new Error('test client create handler not configured')
}
return createHandler(...args)
let restoreClientSpy: (() => void) | undefined

function installClientSpy(): void {
const clientSpy = spyOn(actualClientModule, 'getAnthropicClient').mockImplementation(
async () => ({
beta: {
messages: {
create: (...args: CreateArgs) => {
if (!createHandler) {
throw new Error('test client create handler not configured')
}
return createHandler(...args)
},
},
},
},
}),
}))
}) as never,
)
restoreClientSpy = () => clientSpy.mockRestore()
}

function makeBetaMessage(
id: string,
Expand Down Expand Up @@ -291,6 +293,7 @@ function setTestMacro(): void {

beforeEach(async () => {
await acquireSharedMutationLock('claude.streamWatchdog.test.ts')
installClientSpy()
setTestMacro()
for (const key of envKeys) {
delete process.env[key]
Expand All @@ -305,6 +308,8 @@ beforeEach(async () => {

afterEach(() => {
try {
restoreClientSpy?.()
restoreClientSpy = undefined
createHandler = undefined
for (const key of envKeys) {
const envKey: string = key
Expand Down Expand Up @@ -336,10 +341,6 @@ afterEach(() => {
}
})

afterAll(() => {
mock.module('./client.js', () => actualClientModule)
})

describe('Claude stream watchdog', () => {
test('falls back when the top-level stream iterator never settles', async () => {
const wedged = makeWedgedStream()
Expand Down
18 changes: 18 additions & 0 deletions src/services/api/errors.openaiCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,21 @@ test('maps tool_call_incompatible category markers to model/tool guidance', () =
expect(text).toContain('rejected tool-calling payloads')
expect(text).toContain('/model')
})

test('maps tool_stream_unsupported without promising a retry after failure', () => {
const error = APIError.generate(
400,
undefined,
'OpenAI API error 400: tool_stream is unsupported [openai_category=tool_stream_unsupported]',
new Headers(),
)

const message = getAssistantMessageFromError(error, 'glm-5.2')
const text = getFirstText(message)

expect(text).toContain('rejected the `tool_stream` parameter')
expect(text).toContain('cannot be streamed')
expect(text).toContain('switch models')
expect(text).toMatch(/(\/model|--model)/)
expect(text).not.toContain('Retrying')
})
6 changes: 6 additions & 0 deletions src/services/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ function mapOpenAICompatibilityFailureToAssistantMessage(options: {
error: 'invalid_request',
})

case 'tool_stream_unsupported':
return createAssistantAPIErrorMessage({
content: `The selected provider rejected the \`tool_stream\` parameter. Tool calls cannot be streamed on this provider. If this persists, switch models via ${switchCmd}.`,
error: 'invalid_request',
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

case 'malformed_provider_response':
return createAssistantAPIErrorMessage({
content: `${API_ERROR_MESSAGE_PREFIX}: Provider returned a malformed response. Confirm endpoint compatibility and check local proxy/network middleware.`,
Expand Down
226 changes: 226 additions & 0 deletions src/services/api/openaiErrorClassification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,232 @@ test('classifies tool compatibility failures', () => {
expect(failure.category).toBe('tool_call_incompatible')
})

test('classifies tool_stream rejection as tool_stream_unsupported (#1950)', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: 'Validation: Unsupported parameter(s): `tool_stream`',
})

expect(failure.category).toBe('tool_stream_unsupported')
expect(failure.retryable).toBe(false)
})

test('prioritizes tool_stream rejection over accompanying tool-call wording', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: 'Invalid parameter tool_stream; tool_calls are not supported by this model',
})

expect(failure.category).toBe('tool_stream_unsupported')
})

test('classifies OpenAI-style unrecognized tool_stream arguments', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: 'Unrecognized request argument supplied: tool_stream',
})

expect(failure.category).toBe('tool_stream_unsupported')
})

test('classifies a tool_stream parameter rejection that is conditional on function calls', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: 'Invalid parameter tool_stream in function calls',
})

expect(failure.category).toBe('tool_stream_unsupported')
})

test('classifies a top-level tool_stream rejection that explains function parameters', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: 'Unsupported parameter tool_stream in function parameters',
})

expect(failure.category).toBe('tool_stream_unsupported')
})

test.each([
"Unknown parameter: 'tool_stream'",
'Invalid parameter: "tool_stream"',
"Parameter 'tool_stream' is not supported",
'Unsupported parameters: tool_stream',
'Unsupported parameter(s): ["tool_stream"]',
'Unsupported parameter: (tool_stream)',
'Unknown parameters: [tool_stream]',
"Parameter 'tool_stream' is unknown",
"'tool_stream' is an unknown parameter",
'Invalid "tool_stream" parameter',
'tool_stream is unsupported',
'Unsupported parameter(s): tool_stream. Tools are available only in non-streaming mode.',
'{"error":{"message":"tool_stream is unsupported"}}',
'{"error":{"message":"tool_stream is not supported"}}',
'{"error":{"message":"Unknown parameter","param":"tool_stream"}}',
'{"error":{"message":"Invalid parameter","param":"tool_stream"}}',
'{"detail":[{"type":"extra_forbidden","loc":["body","tool_stream"],"msg":"Extra inputs are not permitted","input":true}]}',
'{"detail":[{"type":"value_error.extra","loc":["body","tool_stream"],"msg":"extra fields not permitted"}]}',
'Additional properties are not allowed ("tool_stream" was unexpected)',
])('classifies quoted tool_stream parameter rejections: %s', body => {
const failure = classifyOpenAIHttpFailure({ status: 400, body })

expect(failure.category).toBe('tool_stream_unsupported')
})

test('classifies a FastAPI validation rejection at its normal 422 status', () => {
const failure = classifyOpenAIHttpFailure({
status: 422,
body: '{"detail":[{"type":"extra_forbidden","loc":["body","tool_stream"],"msg":"Extra inputs are not permitted","input":true}]}',
})

expect(failure.category).toBe('tool_stream_unsupported')
})

test('classifies a root structured tool_stream unsupported message', () => {
const failure = classifyOpenAIHttpFailure({
status: 422,
body: JSON.stringify({
detail: [{
type: 'value_error',
loc: ['body', 'tool_stream'],
msg: 'tool_stream is unsupported',
}],
}),
})

expect(failure.category).toBe('tool_stream_unsupported')
})

test('classifies a root tool_stream extra-field rejection alongside tool validation details', () => {
const failure = classifyOpenAIHttpFailure({
status: 422,
body: JSON.stringify({
detail: [
{
type: 'extra_forbidden',
loc: ['body', 'tool_stream'],
msg: 'Extra inputs are not permitted',
},
{
type: 'missing',
loc: ['body', 'tools'],
msg: 'Field required',
},
],
}),
})

expect(failure.category).toBe('tool_stream_unsupported')
})

test('does not classify a generic 400 as tool_stream_unsupported', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: 'Invalid request: missing required field `messages`',
})

expect(failure.category).not.toBe('tool_stream_unsupported')
})

test.each([
'Tool "tool_stream" is unsupported',
"Function 'tool_stream' is invalid",
'Tool: tool_stream is unsupported',
'Function: tool_stream is invalid',
'tool_stream is unsupported as a function',
'tool_stream is unsupported as a tool',
'Additional properties are not allowed in function tool_stream',
'The tool named "tool_stream" is unsupported',
'Function name tool_stream is invalid',
])('does not classify a tool name error as a tool_stream parameter rejection: %s', body => {
const failure = classifyOpenAIHttpFailure({ status: 400, body })

expect(failure.category).not.toBe('tool_stream_unsupported')
})

test('does not classify an invalid schema for a tool named tool_stream as a parameter rejection', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: "Invalid schema for function 'tool_stream': properties must be an object",
})

expect(failure.category).not.toBe('tool_stream_unsupported')
})

test('does not classify a raw tool-schema property error as a parameter rejection', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: "Invalid schema for function 'Bash': Additional properties are not allowed ('tool_stream' was unexpected)",
})

expect(failure.category).not.toBe('tool_stream_unsupported')
})

test('does not classify a tool-schema error whose location follows the parameter name', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: 'Additional properties are not allowed (tool_stream was unexpected) at body.tools.0.function.parameters.properties',
})

expect(failure.category).not.toBe('tool_stream_unsupported')
})

test.each([
'Invalid schema: param=tool_stream',
'Malformed tool schema: unexpected property tool_stream',
'Additional properties are not allowed: tool_stream in tool definition',
'Invalid parameter tool_stream in function definition',
'Additional properties are not allowed (tool_stream was unexpected) in the function parameters',
'Invalid parameter tool_stream in function Bash',
'Invalid parameter tool_stream in the function Bash',
'Invalid parameter tool_stream for tool Bash',
'At body.tools[0].function.parameters: Extra inputs are not permitted: tool_stream',
'Unexpected field tool_stream in tool schema',
'Extra inputs are not permitted: tool_stream in function parameters',
'tool_stream unexpected field in tool schema',
])('does not classify a generic schema diagnostic as a parameter rejection: %s', body => {
const failure = classifyOpenAIHttpFailure({ status: 400, body })

expect(failure.category).not.toBe('tool_stream_unsupported')
})

test('does not classify a structured validation error that merely references tool_stream', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: '{"error":{"message":"Parameter is required","param":"tool_stream"}}',
})

expect(failure.category).not.toBe('tool_stream_unsupported')
})

test.each([
'{"detail":[{"type":"missing","loc":["body","tool_stream"],"msg":"Field required"}]}',
'{"detail":[{"type":"string_type","loc":["body","tool_stream"],"msg":"Input should be a valid string"}]}',
'{"detail":[{"type":"extra_forbidden","loc":["body","tool_stream","mode"],"msg":"Extra inputs are not permitted"}]}',
])('does not classify a structured validation error for a supported tool_stream field: %s', body => {
const failure = classifyOpenAIHttpFailure({ status: 400, body })

expect(failure.category).not.toBe('tool_stream_unsupported')
})

test('does not classify a structured validation error for a tool named tool_stream', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: '{"detail":[{"type":"extra_forbidden","loc":["body","tools",0,"function","name"],"msg":"Extra inputs are not permitted","input":"tool_stream"}]}',
})

expect(failure.category).not.toBe('tool_stream_unsupported')
})

test('does not classify a structured validation error for a tool-schema property named tool_stream', () => {
const failure = classifyOpenAIHttpFailure({
status: 400,
body: '{"detail":[{"type":"extra_forbidden","loc":["body","tools",0,"function","parameters","properties","tool_stream"],"msg":"Extra inputs are not permitted","input":{}}]}',
})

expect(failure.category).not.toBe('tool_stream_unsupported')
})

test('embeds and extracts category markers in formatted messages', () => {
const marker = formatOpenAICategoryMarker('endpoint_not_found')
expect(marker).toBe('[openai_category=endpoint_not_found]')
Expand Down
Loading