Skip to content

Commit b7ab2c8

Browse files
authored
fix(providers): only flag reasoning=true for known OpenAI reasoning models (#183) (#187)
Fixes #183. ## Problem Custom providers on OpenAI-compatible gateways (Qwen/DashScope, DeepSeek, GLM/BigModel, Moonshot, any `openai-chat` wire pointing to a non-OpenAI baseUrl) returned: ``` 400 developer is not one of ['system', 'assistant', 'user', 'tool', 'function'] ``` ## Root cause `packages/providers/src/index.ts::synthesizeWireModel` hard-coded `reasoning: true` on every synthetic `PiModel`. pi-ai's openai-chat / openai-responses adapters treat `model.reasoning === true` as "this endpoint supports the Responses API `developer` role" and rewrite the system prompt role accordingly. `developer` is OpenAI-Responses-only (GPT-5 / o-family); no third-party OpenAI-compat gateway accepts it. ## Fix New `inferReasoning(wire, modelId, baseUrl)`: - `anthropic` -> `true` - `openai-responses` / `openai-codex-responses` -> `true` (preserves #134) - `openai-chat` -> `true` only when baseUrl is `api.openai.com` AND modelId matches `^(o[134]|gpt-5)` (OpenAI reasoning families) - otherwise -> `false` ## Coverage Unblocks every OpenAI-compatible Chinese gateway and any generic OpenAI-compat endpoint: - Qwen / DashScope (`dashscope.aliyuncs.com`) - DeepSeek (`api.deepseek.com`) - GLM / Zhipu BigModel (`open.bigmodel.cn`) - Moonshot / Kimi - Any user-configured LiteLLM / Azure / self-hosted openai-chat gateway ## Tests `packages/providers/src/index.test.ts` — 9 new `inferReasoning` cases + 1 integration case asserting Qwen DashScope gets `reasoning: false` through `complete()`. ## Four-principle check (PRINCIPLES §5b) - Compatibility: green — #134 (openai-responses) + #175 unchanged; Anthropic unchanged - Upgradeability: green — central helper; adding new reasoning model families is a regex edit - No bloat: green — single helper function, no new file, no new dep - Elegance: green — intent-revealing name; replaces a hard-coded `true` with a predicate ## Out of scope Didn't touch retry / errors / Settings / agent.ts. Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 011b25d commit b7ab2c8

3 files changed

Lines changed: 127 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@open-codesign/providers": patch
3+
---
4+
5+
Fix 400 "developer is not one of ['system', 'assistant', 'user', 'tool', 'function']" when talking to OpenAI-compatible gateways (Qwen/DashScope, DeepSeek, GLM/BigModel, Moonshot, …) through a custom provider. `synthesizeWireModel` no longer hard-codes `reasoning: true`; it only flags reasoning for Anthropic, openai-responses, openai-codex-responses, or OpenAI-official endpoints on known reasoning model families (o1/o3/o4/gpt-5). (#183)

packages/providers/src/index.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ vi.mock('@mariozechner/pi-ai', () => ({
99
completeSimple: (...args: unknown[]) => completeSimpleMock(...args),
1010
}));
1111

12-
import { complete } from './index';
12+
import { complete, inferReasoning } from './index';
1313

1414
const MODEL: ModelRef = { provider: 'openai', modelId: 'gpt-4o' };
1515

@@ -280,6 +280,42 @@ describe('complete', () => {
280280
expect(result.content).toBe('ok');
281281
});
282282

283+
it('synthesizes openai-chat PiModel with reasoning=false for Qwen DashScope (#183)', async () => {
284+
getModelMock.mockReturnValue(undefined);
285+
completeSimpleMock.mockImplementationOnce(async (model) => {
286+
expect(model.reasoning).toBe(false);
287+
expect(model.api).toBe('openai-completions');
288+
expect(model.baseUrl).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1');
289+
return {
290+
role: 'assistant',
291+
content: [{ type: 'text', text: 'ok' }],
292+
api: 'openai-completions',
293+
provider: 'custom-qwen',
294+
model: 'qwen3.6-plus',
295+
usage: {
296+
input: 1,
297+
output: 1,
298+
cacheRead: 0,
299+
cacheWrite: 0,
300+
totalTokens: 2,
301+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
302+
},
303+
stopReason: 'stop',
304+
timestamp: Date.now(),
305+
};
306+
});
307+
308+
await complete(
309+
{ provider: 'custom-qwen', modelId: 'qwen3.6-plus' },
310+
[{ role: 'user', content: 'hi' }],
311+
{
312+
apiKey: 'sk-test',
313+
wire: 'openai-chat',
314+
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
315+
},
316+
);
317+
});
318+
283319
it('rejects oversized combined image inputs for openai-codex-responses', async () => {
284320
getModelMock.mockReturnValue({
285321
id: 'gpt-5.4',
@@ -472,3 +508,51 @@ describe('complete — openai-responses strict instructions', () => {
472508
);
473509
});
474510
});
511+
512+
describe('inferReasoning', () => {
513+
it('returns false for Qwen DashScope via openai-chat (#183)', () => {
514+
expect(
515+
inferReasoning(
516+
'openai-chat',
517+
'qwen3.6-plus',
518+
'https://dashscope.aliyuncs.com/compatible-mode/v1',
519+
),
520+
).toBe(false);
521+
});
522+
523+
it('returns false for DeepSeek via openai-chat', () => {
524+
expect(inferReasoning('openai-chat', 'deepseek-chat', 'https://api.deepseek.com/v1')).toBe(
525+
false,
526+
);
527+
});
528+
529+
it('returns false for GLM (BigModel) via openai-chat', () => {
530+
expect(inferReasoning('openai-chat', 'glm-4.6v', 'https://open.bigmodel.cn/api/paas/v4')).toBe(
531+
false,
532+
);
533+
});
534+
535+
it('returns false for OpenAI official non-reasoning model (gpt-4o)', () => {
536+
expect(inferReasoning('openai-chat', 'gpt-4o', 'https://api.openai.com/v1')).toBe(false);
537+
});
538+
539+
it('returns true for OpenAI official gpt-5 family via openai-chat', () => {
540+
expect(inferReasoning('openai-chat', 'gpt-5-turbo', 'https://api.openai.com/v1')).toBe(true);
541+
});
542+
543+
it('returns true for OpenAI official o3 family via openai-chat', () => {
544+
expect(inferReasoning('openai-chat', 'o3-mini', 'https://api.openai.com/v1')).toBe(true);
545+
});
546+
547+
it('returns true for openai-responses regardless of model id (preserves #134 fix)', () => {
548+
expect(inferReasoning('openai-responses', 'gpt-5.4', 'https://proxy.example/v1')).toBe(true);
549+
});
550+
551+
it('returns true for anthropic wire', () => {
552+
expect(inferReasoning('anthropic', 'claude-opus-4-5', 'https://api.anthropic.com')).toBe(true);
553+
});
554+
555+
it('returns false when wire is undefined', () => {
556+
expect(inferReasoning(undefined, 'gpt-4o', 'https://api.openai.com/v1')).toBe(false);
557+
});
558+
});

packages/providers/src/index.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,42 @@ const EMPTY_USAGE: PiUsage = {
171171

172172
const MAX_TOTAL_CODEX_IMAGE_BYTES = 4_000_000;
173173

174+
/**
175+
* `reasoning: true` on a synthesized PiModel makes pi-ai's openai-responses /
176+
* openai-chat adapters write the system prompt with role `'developer'`
177+
* instead of `'system'`. That's OpenAI-Responses-only; every OpenAI-compat
178+
* gateway out there (DashScope/Qwen, DeepSeek, GLM/BigModel, Moonshot, …)
179+
* rejects `developer` with HTTP 400. So only claim reasoning when we
180+
* actually know the target accepts it. (#183)
181+
*/
182+
function isOpenAIOfficial(baseUrl: string | undefined): boolean {
183+
if (!baseUrl) return false;
184+
return /^https:\/\/api\.openai\.com(\/|$)/.test(baseUrl);
185+
}
186+
187+
function isReasoningModelId(modelId: string): boolean {
188+
// OpenAI reasoning families: o1, o3, o4, gpt-5 (incl. variants like gpt-5-turbo, gpt-5.4)
189+
return /^(o[134]|gpt-5)/i.test(modelId);
190+
}
191+
192+
export function inferReasoning(
193+
wire: GenerateOptions['wire'],
194+
modelId: string,
195+
baseUrl: string | undefined,
196+
): boolean {
197+
switch (wire) {
198+
case 'anthropic':
199+
return true;
200+
case 'openai-responses':
201+
case 'openai-codex-responses':
202+
return true;
203+
case 'openai-chat':
204+
return isOpenAIOfficial(baseUrl) && isReasoningModelId(modelId);
205+
default:
206+
return false;
207+
}
208+
}
209+
174210
/**
175211
* Synthesize a PiModel for a wire + custom baseUrl so custom provider ids
176212
* (DeepSeek, Ollama, LiteLLM, Azure, …) route to the correct pi-ai adapter
@@ -196,7 +232,7 @@ function synthesizeWireModel(
196232
name: modelId,
197233
api,
198234
provider,
199-
reasoning: true,
235+
reasoning: inferReasoning(wire, modelId, baseUrl),
200236
input: supportsImageInput ? ['text', 'image'] : ['text'],
201237
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
202238
contextWindow: 131072,

0 commit comments

Comments
 (0)