fix(gate): stop masking backend errors + rescue thinking-model prompt selection
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m14s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / smoke (pull_request) Failing after 1m54s
CI/CD / build (pull_request) Successful in 4m17s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m14s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / smoke (pull_request) Failing after 1m54s
CI/CD / build (pull_request) Successful in 4m17s
CI/CD / publish (pull_request) Has been skipped
Two independent, live-reproduced causes of "Smart prompt-selection unavailable
(LLM response did not contain valid selection JSON)" in the gate:
- Cause B (openai.ts request): the response handler JSON.parsed the body with no
res.statusCode check, so a LiteLLM/vLLM HTTP 500 ("Connection error") parsed as
an empty completion and surfaced as generic "bad JSON", masking backend-down.
Now reject on status >= 400 with "OpenAI HTTP <status>: <body>" so the selector
reports the true degradedReason.
- Cause A (parseResponse + selector budget): a reasoning model (qwen3-thinking)
spends its whole 1024-token budget in reasoning_content and emits content=null,
so the {selectedNames} JSON never appears. parseResponse now falls back to
reasoning_content (incl. provider_specific_fields) when content is empty, and
the selector budget is raised 1024 -> 4000 (env MCPCTL_GATE_SELECT_MAX_TOKENS)
so thinking + the JSON answer both fit.
Note: mcpd's chat path got a reasoning_content fallback in PR #67; this is the
separate mcplocal gate-selection provider, which never did. Tests: openai-provider
(500 rejects, reasoning_content capture/preference). mcplocal suite green (749).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
74
src/mcplocal/tests/openai-provider.test.ts
Normal file
74
src/mcplocal/tests/openai-provider.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* OpenAiProvider — transport-error surfacing and reasoning_content capture.
|
||||
*
|
||||
* Covers the two gate prompt-ranking failure modes reproduced live 2026-07-23:
|
||||
* - a LiteLLM/vLLM HTTP 500 must reject (not parse as an empty completion),
|
||||
* - a reasoning ("thinking") model that emits its answer under
|
||||
* reasoning_content with content=null must still yield that text.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
let mockStatus = 200;
|
||||
let mockBody = '{}';
|
||||
|
||||
vi.mock('node:http', () => {
|
||||
const request = vi.fn((_opts: unknown, cb?: unknown) => {
|
||||
const res = {
|
||||
statusCode: mockStatus,
|
||||
on: (event: string, handler: (d?: unknown) => void) => {
|
||||
if (event === 'data') handler(Buffer.from(mockBody));
|
||||
if (event === 'end') handler();
|
||||
return res;
|
||||
},
|
||||
};
|
||||
if (typeof cb === 'function') (cb as (r: unknown) => void)(res);
|
||||
return { on: vi.fn().mockReturnThis(), write: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
return { default: { request }, request };
|
||||
});
|
||||
|
||||
const { OpenAiProvider } = await import('../src/providers/openai.js');
|
||||
|
||||
function provider(): InstanceType<typeof OpenAiProvider> {
|
||||
return new OpenAiProvider({ apiKey: 'k', baseUrl: 'http://localhost:9' });
|
||||
}
|
||||
|
||||
describe('OpenAiProvider transport + reasoning handling', () => {
|
||||
beforeEach(() => {
|
||||
mockStatus = 200;
|
||||
mockBody = '{}';
|
||||
});
|
||||
|
||||
it('rejects on HTTP >= 400 with the status + body (not a masked empty completion)', async () => {
|
||||
mockStatus = 500;
|
||||
mockBody = JSON.stringify({ error: { message: 'litellm.InternalServerError: Connection error' } });
|
||||
await expect(
|
||||
provider().complete({ messages: [{ role: 'user', content: 'hi' }], maxTokens: 8 }),
|
||||
).rejects.toThrow(/OpenAI HTTP 500/);
|
||||
});
|
||||
|
||||
it('falls back to reasoning_content when content is null (thinking model)', async () => {
|
||||
mockBody = JSON.stringify({
|
||||
choices: [{ message: { content: null, reasoning_content: '{"selectedNames":["x"]}' }, finish_reason: 'length' }],
|
||||
});
|
||||
const r = await provider().complete({ messages: [{ role: 'user', content: 'hi' }], maxTokens: 8 });
|
||||
expect(r.content).toContain('selectedNames');
|
||||
expect(r.finishReason).toBe('length');
|
||||
});
|
||||
|
||||
it('reads reasoning_content from provider_specific_fields too', async () => {
|
||||
mockBody = JSON.stringify({
|
||||
choices: [{ message: { content: '', provider_specific_fields: { reasoning_content: 'thought-answer' } } }],
|
||||
});
|
||||
const r = await provider().complete({ messages: [{ role: 'user', content: 'hi' }], maxTokens: 8 });
|
||||
expect(r.content).toBe('thought-answer');
|
||||
});
|
||||
|
||||
it('prefers real content over reasoning_content when both are present', async () => {
|
||||
mockBody = JSON.stringify({
|
||||
choices: [{ message: { content: 'real', reasoning_content: 'thinking' }, finish_reason: 'stop' }],
|
||||
});
|
||||
const r = await provider().complete({ messages: [{ role: 'user', content: 'hi' }], maxTokens: 8 });
|
||||
expect(r.content).toBe('real');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user