mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-26 00:53:48 +00:00
fix(core): emit OpenRouter's reasoning disable when thinking is off (#9758)
* fix(core): emit OpenRouter's reasoning disable when thinking is off The AUTO-mode permission classifier's stage-1 side query forces a respond_in_schema tool call (tool_choice: 'required') with a 256-token budget and includeThoughts: false. On OpenRouter endpoints the thinking-disable rendered only into shapes the gateway ignores (chat_template_kwargs.enable_thinking for qwen-family models), and the pipeline's unconditional strip then removed the `reasoning` object — OpenRouter's native thinking knob. Thinking stayed on, the model spent the whole budget on reasoning, never emitted the tool call, and the classifier fail-closed with "Classifier stage 1 unavailable" (#9757). Mirror the isDeepSeekHostname precedent: hostname-gated detection (openrouter.ai / *.openrouter.ai) and emit `reasoning: { enabled: false }` in the reasoningDisabled branch after the strip — the provider buildRequest hook runs before the strip, so emitting earlier would be removed again. Applied endpoint-wide rather than qwen-family-gated: `reasoning` is an OpenRouter provider-level parameter, unlike `enable_thinking`, which is a qwen wire field that leaks upstream on non-qwen routings. thinkingMandatory models stay exempt; DashScope (both shapes), vLLM/SGLang, DeepSeek hostname, and the official OpenAI endpoint are untouched. Repro + regression coverage added in pipeline.test.ts (red before the fix, green after). * test(core): cover OpenRouter reasoning guard
This commit is contained in:
parent
923eb12a78
commit
bd42e67137
5 changed files with 404 additions and 0 deletions
|
|
@ -657,6 +657,8 @@ Setting `reasoning: false` (the literal boolean) explicitly disables thinking on
|
|||
|
||||
On a `api.deepseek.com` baseURL, the OpenAI pipeline emits the explicit `thinking: { type: 'disabled' }` field that DeepSeek V4+ requires — the server-side default is `'enabled'`, so simply omitting `reasoning_effort` would still pay thinking latency/cost. Self-hosted DeepSeek backends (sglang/vllm) and other OpenAI-compatible servers do **not** receive this field; if you need to disable thinking on those, inject `thinking: { type: 'disabled' }` (or whatever knob your inference framework exposes) via `samplingParams`/`extra_body`.
|
||||
|
||||
On an `openrouter.ai` baseURL, the OpenAI pipeline emits OpenRouter's provider-level `reasoning: { enabled: false }` field when reasoning is disabled. Other OpenAI-compatible servers do not receive this OpenRouter-specific field; use `samplingParams`/`extra_body` for their native disable knob.
|
||||
|
||||
### Interaction with `samplingParams` (OpenAI-compatible only)
|
||||
|
||||
> [!warning]
|
||||
|
|
|
|||
|
|
@ -1838,6 +1838,319 @@ describe('ContentGenerationPipeline', () => {
|
|||
expect(apiCall.thinking).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits reasoning.enabled=false on OpenRouter hostname when includeThoughts is false', async () => {
|
||||
// Regression for #9757: OpenRouter's native thinking switch is the
|
||||
// provider-level `reasoning` parameter. The disable path emits only
|
||||
// shapes OpenRouter ignores (chat_template_kwargs for qwen-family
|
||||
// models) and strips any `reasoning` object, so thinking-capable
|
||||
// models routed through OpenRouter keep thinking enabled. The
|
||||
// AUTO-mode classifier's stage-1 side query (256-token budget,
|
||||
// forced respond_in_schema tool call, includeThoughts: false) then
|
||||
// spends its whole budget on reasoning, never emits the tool call,
|
||||
// and fail-closes with "Classifier stage 1 unavailable". Verify the
|
||||
// OpenRouter-native disable shape is emitted.
|
||||
mockContentGeneratorConfig = {
|
||||
...mockContentGeneratorConfig,
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'qwen/qwen3.8-27b',
|
||||
} as ContentGeneratorConfig;
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
contentGeneratorConfig: mockContentGeneratorConfig,
|
||||
};
|
||||
pipeline = new ContentGenerationPipeline(mockConfig);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen/qwen3.8-27b',
|
||||
contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }],
|
||||
config: { thinkingConfig: { includeThoughts: false } },
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
|
||||
{ role: 'user', content: 'Classify action' },
|
||||
]);
|
||||
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
|
||||
new GenerateContentResponse(),
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue({
|
||||
id: 'r',
|
||||
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
|
||||
} as OpenAI.Chat.ChatCompletion);
|
||||
|
||||
await pipeline.execute(request, 'side-query:permission-classifier');
|
||||
|
||||
const apiCall = (mockClient.chat.completions.create as Mock).mock
|
||||
.calls[0][0];
|
||||
expect(apiCall.reasoning).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('does NOT emit reasoning.enabled=false on OpenRouter when thinking is enabled', async () => {
|
||||
mockContentGeneratorConfig = {
|
||||
...mockContentGeneratorConfig,
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'qwen/qwen3.8-27b',
|
||||
} as ContentGeneratorConfig;
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
contentGeneratorConfig: mockContentGeneratorConfig,
|
||||
};
|
||||
pipeline = new ContentGenerationPipeline(mockConfig);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen/qwen3.8-27b',
|
||||
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
|
||||
{ role: 'user', content: 'Hello' },
|
||||
]);
|
||||
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
|
||||
new GenerateContentResponse(),
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue({
|
||||
id: 'r',
|
||||
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
|
||||
} as OpenAI.Chat.ChatCompletion);
|
||||
|
||||
await pipeline.execute(request, 'main');
|
||||
|
||||
const apiCall = (mockClient.chat.completions.create as Mock).mock
|
||||
.calls[0][0];
|
||||
expect(apiCall.reasoning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits reasoning.enabled=false on OpenRouter hostname when reasoning is configured to false', async () => {
|
||||
// Config-level opt-out (`reasoning: false`) must also land OpenRouter's
|
||||
// native disable shape, matching the DeepSeek hostname branch.
|
||||
mockContentGeneratorConfig = {
|
||||
...mockContentGeneratorConfig,
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'qwen/qwen3.8-27b',
|
||||
reasoning: false,
|
||||
} as ContentGeneratorConfig;
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
contentGeneratorConfig: mockContentGeneratorConfig,
|
||||
};
|
||||
pipeline = new ContentGenerationPipeline(mockConfig);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen/qwen3.8-27b',
|
||||
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
|
||||
{ role: 'user', content: 'Hello' },
|
||||
]);
|
||||
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
|
||||
new GenerateContentResponse(),
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue({
|
||||
id: 'r',
|
||||
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
|
||||
} as OpenAI.Chat.ChatCompletion);
|
||||
|
||||
await pipeline.execute(request, 'main');
|
||||
|
||||
const apiCall = (mockClient.chat.completions.create as Mock).mock
|
||||
.calls[0][0];
|
||||
expect(apiCall.reasoning).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('emits reasoning.enabled=false on OpenRouter for non-qwen models too', async () => {
|
||||
// `reasoning` is an OpenRouter provider-level parameter the gateway
|
||||
// routes to any model that supports it — not a qwen-family wire field
|
||||
// like `enable_thinking`. Gating on the model family would leave every
|
||||
// other thinking model on OpenRouter broken the same way.
|
||||
mockContentGeneratorConfig = {
|
||||
...mockContentGeneratorConfig,
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'deepseek/deepseek-r1',
|
||||
} as ContentGeneratorConfig;
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
contentGeneratorConfig: mockContentGeneratorConfig,
|
||||
};
|
||||
pipeline = new ContentGenerationPipeline(mockConfig);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'deepseek/deepseek-r1',
|
||||
contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }],
|
||||
config: { thinkingConfig: { includeThoughts: false } },
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
|
||||
{ role: 'user', content: 'Classify action' },
|
||||
]);
|
||||
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
|
||||
new GenerateContentResponse(),
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue({
|
||||
id: 'r',
|
||||
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
|
||||
} as OpenAI.Chat.ChatCompletion);
|
||||
|
||||
await pipeline.execute(request, 'side-query:permission-classifier');
|
||||
|
||||
const apiCall = (mockClient.chat.completions.create as Mock).mock
|
||||
.calls[0][0];
|
||||
expect(apiCall.reasoning).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('does NOT emit reasoning.enabled=false for thinking-mandatory models on OpenRouter', async () => {
|
||||
// thinkingMandatory marks models that reject a thinking-disable shape
|
||||
// with a 400; the exemption must hold on OpenRouter too.
|
||||
mockContentGeneratorConfig = {
|
||||
...mockContentGeneratorConfig,
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'qwen/qwen3.8-27b',
|
||||
thinkingMandatory: true,
|
||||
} as ContentGeneratorConfig;
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
contentGeneratorConfig: mockContentGeneratorConfig,
|
||||
};
|
||||
pipeline = new ContentGenerationPipeline(mockConfig);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen/qwen3.8-27b',
|
||||
contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }],
|
||||
config: { thinkingConfig: { includeThoughts: false } },
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
|
||||
{ role: 'user', content: 'Classify action' },
|
||||
]);
|
||||
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
|
||||
new GenerateContentResponse(),
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue({
|
||||
id: 'r',
|
||||
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
|
||||
} as OpenAI.Chat.ChatCompletion);
|
||||
|
||||
await pipeline.execute(request, 'forked_query');
|
||||
|
||||
const apiCall = (mockClient.chat.completions.create as Mock).mock
|
||||
.calls[0][0];
|
||||
expect(apiCall.reasoning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT emit reasoning on a non-OpenRouter OpenAI-compatible endpoint', async () => {
|
||||
// The disable shape is OpenRouter-specific wire shape; other
|
||||
// OpenAI-compatible gateways (vLLM/SGLang/strict-compat) must not
|
||||
// receive the extra `reasoning` field.
|
||||
mockContentGeneratorConfig = {
|
||||
...mockContentGeneratorConfig,
|
||||
baseUrl: 'https://my-vllm.example.com:8000/v1',
|
||||
model: 'qwen/qwen3-32b',
|
||||
} as ContentGeneratorConfig;
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
contentGeneratorConfig: mockContentGeneratorConfig,
|
||||
};
|
||||
pipeline = new ContentGenerationPipeline(mockConfig);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen/qwen3-32b',
|
||||
contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }],
|
||||
config: { thinkingConfig: { includeThoughts: false } },
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
|
||||
{ role: 'user', content: 'Classify action' },
|
||||
]);
|
||||
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
|
||||
new GenerateContentResponse(),
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue({
|
||||
id: 'r',
|
||||
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
|
||||
} as OpenAI.Chat.ChatCompletion);
|
||||
|
||||
await pipeline.execute(request, 'forked_query');
|
||||
|
||||
const apiCall = (mockClient.chat.completions.create as Mock).mock
|
||||
.calls[0][0];
|
||||
expect(apiCall.reasoning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT treat lookalike hostnames as OpenRouter', async () => {
|
||||
// Hostname match must be exact (openrouter.ai or *.openrouter.ai); a
|
||||
// substring check would false-positive on hostile hosts.
|
||||
mockContentGeneratorConfig = {
|
||||
...mockContentGeneratorConfig,
|
||||
baseUrl: 'https://openrouter.ai.evil.com/v1',
|
||||
model: 'qwen/qwen3.8-27b',
|
||||
} as ContentGeneratorConfig;
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
contentGeneratorConfig: mockContentGeneratorConfig,
|
||||
};
|
||||
pipeline = new ContentGenerationPipeline(mockConfig);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen/qwen3.8-27b',
|
||||
contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }],
|
||||
config: { thinkingConfig: { includeThoughts: false } },
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
|
||||
{ role: 'user', content: 'Classify action' },
|
||||
]);
|
||||
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
|
||||
new GenerateContentResponse(),
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue({
|
||||
id: 'r',
|
||||
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
|
||||
} as OpenAI.Chat.ChatCompletion);
|
||||
|
||||
await pipeline.execute(request, 'forked_query');
|
||||
|
||||
const apiCall = (mockClient.chat.completions.create as Mock).mock
|
||||
.calls[0][0];
|
||||
expect(apiCall.reasoning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT emit reasoning on the official OpenAI endpoint when includeThoughts is false', async () => {
|
||||
// The new OpenRouter branch must not leak onto api.openai.com, which
|
||||
// has its own reasoning shapes and rejects unknown fields.
|
||||
mockContentGeneratorConfig = {
|
||||
...mockContentGeneratorConfig,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-5',
|
||||
} as ContentGeneratorConfig;
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
contentGeneratorConfig: mockContentGeneratorConfig,
|
||||
};
|
||||
pipeline = new ContentGenerationPipeline(mockConfig);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'gpt-5',
|
||||
contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }],
|
||||
config: { thinkingConfig: { includeThoughts: false } },
|
||||
};
|
||||
|
||||
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
|
||||
{ role: 'user', content: 'Classify action' },
|
||||
]);
|
||||
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
|
||||
new GenerateContentResponse(),
|
||||
);
|
||||
(mockClient.chat.completions.create as Mock).mockResolvedValue({
|
||||
id: 'r',
|
||||
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
|
||||
} as OpenAI.Chat.ChatCompletion);
|
||||
|
||||
await pipeline.execute(request, 'forked_query');
|
||||
|
||||
const apiCall = (mockClient.chat.completions.create as Mock).mock
|
||||
.calls[0][0];
|
||||
expect(apiCall.reasoning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits enable_thinking:false on DashScope hostname when includeThoughts is false', async () => {
|
||||
// Regression for #4501: qwen3 hybrid models (e.g. qwen3.5-flash)
|
||||
// default to thinking-on. Provider buildRequest never auto-injects
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
isOfficialOpenAIEndpoint,
|
||||
} from './prefix-caching.js';
|
||||
import { isDeepSeekHostname } from './provider/deepseek.js';
|
||||
import { isOpenRouterHostname } from './provider/openrouter.js';
|
||||
import { openaiRequestCaptureContext } from './requestCaptureContext.js';
|
||||
import { StreamingToolCallParser } from './streamingToolCallParser.js';
|
||||
import { TaggedThinkingParser } from './taggedThinkingParser.js';
|
||||
|
|
@ -1215,6 +1216,30 @@ export class ContentGenerationPipeline {
|
|||
if (isDeepSeekHostname(this.contentGeneratorConfig)) {
|
||||
typed['thinking'] = { type: 'disabled' };
|
||||
}
|
||||
// OpenRouter's thinking switch is the provider-level `reasoning`
|
||||
// parameter (`reasoning: { enabled: false }`, see
|
||||
// https://openrouter.ai/docs/features/reasoning-tokens). The shapes
|
||||
// emitted above are ignored by the gateway, and the strip just above
|
||||
// removes any `reasoning` object a provider hook injected — so
|
||||
// thinking-capable models routed through OpenRouter keep thinking on.
|
||||
// That breaks the AUTO-mode classifier's stage-1 side query (#9757):
|
||||
// the 256-token budget is spent on reasoning, the forced
|
||||
// respond_in_schema tool call never ships, and the classifier
|
||||
// fail-closes. Must be emitted after the strip, which runs later
|
||||
// than the provider buildRequest hook.
|
||||
//
|
||||
// Provider-level, not model-family-gated: unlike `enable_thinking`
|
||||
// (a qwen-family wire field that leaks upstream on non-qwen
|
||||
// routings), `reasoning` is an OpenRouter API parameter the gateway
|
||||
// applies to whatever model supports it. `thinkingMandatory` models
|
||||
// stay exempt: a disable shape they reject would be a guaranteed
|
||||
// request failure.
|
||||
if (
|
||||
!thinkingMandatory &&
|
||||
isOpenRouterHostname(this.contentGeneratorConfig)
|
||||
) {
|
||||
typed['reasoning'] = { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (thinkingMandatory) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
import { isOpenRouterHostname } from './openrouter.js';
|
||||
|
||||
describe('isOpenRouterHostname', () => {
|
||||
it.each([
|
||||
['https://openrouter.ai/api/v1', true],
|
||||
['https://eu.openrouter.ai/api/v1', true],
|
||||
['https://openrouter.ai.evil.com/v1', false],
|
||||
['https://evilopenrouter.ai/v1', false],
|
||||
['not a url', false],
|
||||
['', false],
|
||||
])('classifies %s as %s', (baseUrl, expected) => {
|
||||
expect(isOpenRouterHostname({ baseUrl } as ContentGeneratorConfig)).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
|
||||
/**
|
||||
* Hostname-only check used to decide whether a thinking-disable request
|
||||
* should carry OpenRouter's provider-level `reasoning` parameter. Mirrors
|
||||
* `isDeepSeekHostname`: hostname matching only — no model-name fallback.
|
||||
* OpenRouter's `reasoning` parameter is a gateway-level extension of the
|
||||
* chat-completions API; pushing it at strict OpenAI-compatible backends
|
||||
* (self-hosted vLLM/SGLang, official OpenAI) could trip an unknown-key
|
||||
* rejection, and those gateways have their own thinking switches anyway.
|
||||
*
|
||||
* Parses the baseUrl with `new URL(...)` and matches the hostname against
|
||||
* `openrouter.ai` (and its subdomains) exactly — a naive substring check
|
||||
* would false-positive on hostile hosts like
|
||||
* `https://openrouter.ai.evil.com/v1`. Invalid URLs are treated as
|
||||
* non-OpenRouter. The hostname shape mirrors `openRouterProvider.ownsModel`
|
||||
* in `providers/presets/openrouter.ts`.
|
||||
*
|
||||
* Exposed as a free function so consumers (the pipeline post-processing
|
||||
* hook, in particular) can run the check without a provider class —
|
||||
* OpenRouter requests flow through `DefaultOpenAICompatibleProvider`.
|
||||
*/
|
||||
export function isOpenRouterHostname(
|
||||
contentGeneratorConfig: ContentGeneratorConfig,
|
||||
): boolean {
|
||||
const baseUrl = contentGeneratorConfig.baseUrl ?? '';
|
||||
if (!baseUrl) return false;
|
||||
try {
|
||||
const hostname = new URL(baseUrl).hostname.toLowerCase();
|
||||
return hostname === 'openrouter.ai' || hostname.endsWith('.openrouter.ai');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue