feat(supplier): env-gated OpenRouter reasoning-off + max_tokens
The supplier sent a bare {model,messages,stream} to the OpenAI/OpenRouter
endpoint — no max_tokens and no reasoning control — so reasoning ("thinking")
models like kimi-k2.6 emitted reasoning tokens that could consume the
completion budget and finish with reason=length (truncated/empty answer =
failed job). deepseek-v4-flash has no thinking; kimi-k2.6 does by default.
Add two optional, env-gated request params (defaults preserve current behavior,
so ChatMock/DeepSeek-direct suppliers are unaffected):
- OPENAI_REASONING=off -> send reasoning:{enabled:false} (disable thinking:
faster, cheaper, straight answers; no reasoning-starved length truncation)
- OPENAI_MAX_TOKENS=N -> forward max_tokens:N. Must stay below the model
context (kimi context = 262144), so the advert's 262144 cap cannot be sent
verbatim (provider 400s when prompt+max_tokens > context).
openai.ts builds the body conditionally; config.ts parses the env; wired
through jobRunner.ts (text) and server.ts (chat-session stream).
Deployed on the 4 kimi + 3 deepseek-flash suppliers with OPENAI_REASONING=off
and OPENAI_MAX_TOKENS=131072 (env files are host-only/gitignored).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,19 @@ export interface SupplierConfig {
|
|||||||
* proxies). Required for hosted OpenAI-compatible APIs (DeepSeek, OpenAI, etc.).
|
* proxies). Required for hosted OpenAI-compatible APIs (DeepSeek, OpenAI, etc.).
|
||||||
*/
|
*/
|
||||||
openaiApiKey: string;
|
openaiApiKey: string;
|
||||||
|
/**
|
||||||
|
* When true, upstream OpenAI/OpenRouter calls send `reasoning:{enabled:false}`
|
||||||
|
* to disable "thinking" tokens (faster/cheaper straight answers; avoids
|
||||||
|
* reasoning starving the completion into a length-truncated empty answer).
|
||||||
|
* Set via OPENAI_REASONING=off. Default false. OpenRouter-only.
|
||||||
|
*/
|
||||||
|
openaiReasoningDisabled: boolean;
|
||||||
|
/**
|
||||||
|
* Optional `max_tokens` ceiling forwarded on upstream calls. 0 = omit (the
|
||||||
|
* provider then grants the max available = context − prompt). Must stay below
|
||||||
|
* the model context or the provider 400s (e.g. kimi context = 262144).
|
||||||
|
*/
|
||||||
|
openaiMaxTokens: number;
|
||||||
/** Empty string when capabilityKind="chat". */
|
/** Empty string when capabilityKind="chat". */
|
||||||
piperUrl: string;
|
piperUrl: string;
|
||||||
advertRef: AdvertRef;
|
advertRef: AdvertRef;
|
||||||
@@ -230,6 +243,31 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
|||||||
|
|
||||||
const openaiApiKey = env.OPENAI_API_KEY ?? "";
|
const openaiApiKey = env.OPENAI_API_KEY ?? "";
|
||||||
|
|
||||||
|
// OPENAI_REASONING — "off"/"false"/"0"/"no" disables OpenRouter reasoning
|
||||||
|
// ("thinking") by sending reasoning:{enabled:false} on every upstream call.
|
||||||
|
// Unset/any other value leaves the model's own default untouched (no param
|
||||||
|
// sent). Only valid for OpenRouter-backed suppliers — do NOT set for
|
||||||
|
// ChatMock/DeepSeek-direct, which reject the param.
|
||||||
|
const reasoningRaw = (env.OPENAI_REASONING ?? "").trim().toLowerCase();
|
||||||
|
const openaiReasoningDisabled =
|
||||||
|
reasoningRaw === "off" ||
|
||||||
|
reasoningRaw === "false" ||
|
||||||
|
reasoningRaw === "0" ||
|
||||||
|
reasoningRaw === "no";
|
||||||
|
|
||||||
|
// OPENAI_MAX_TOKENS — optional hard ceiling forwarded as `max_tokens`. Unset
|
||||||
|
// or non-positive omits it (provider grants max available = context − prompt).
|
||||||
|
// Keep it below the model context (kimi 262144) or the provider 400s when
|
||||||
|
// prompt+max_tokens exceeds context.
|
||||||
|
const maxTokStr = env.OPENAI_MAX_TOKENS;
|
||||||
|
let openaiMaxTokens = 0;
|
||||||
|
if (maxTokStr !== undefined && maxTokStr !== "") {
|
||||||
|
if (!POS_INT_RE.test(maxTokStr)) {
|
||||||
|
throw new Error("loadConfig: OPENAI_MAX_TOKENS must be a positive integer");
|
||||||
|
}
|
||||||
|
openaiMaxTokens = Number(maxTokStr);
|
||||||
|
}
|
||||||
|
|
||||||
// CHAT_IDLE_TIMEOUT_MS — idle auto-end window for chat sessions (default 5 min).
|
// CHAT_IDLE_TIMEOUT_MS — idle auto-end window for chat sessions (default 5 min).
|
||||||
const chatIdleStr = env.CHAT_IDLE_TIMEOUT_MS;
|
const chatIdleStr = env.CHAT_IDLE_TIMEOUT_MS;
|
||||||
let chatIdleTimeoutMs = 300_000;
|
let chatIdleTimeoutMs = 300_000;
|
||||||
@@ -270,6 +308,8 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
|||||||
ollamaUrl,
|
ollamaUrl,
|
||||||
openaiBaseUrl,
|
openaiBaseUrl,
|
||||||
openaiApiKey,
|
openaiApiKey,
|
||||||
|
openaiReasoningDisabled,
|
||||||
|
openaiMaxTokens,
|
||||||
piperUrl,
|
piperUrl,
|
||||||
advertRef,
|
advertRef,
|
||||||
networkId,
|
networkId,
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
|||||||
messages: requestBody.messages,
|
messages: requestBody.messages,
|
||||||
timeoutMs: deps.config.openaiTimeoutMs,
|
timeoutMs: deps.config.openaiTimeoutMs,
|
||||||
apiKey: deps.config.openaiApiKey,
|
apiKey: deps.config.openaiApiKey,
|
||||||
|
maxTokens: deps.config.openaiMaxTokens,
|
||||||
|
disableReasoning: deps.config.openaiReasoningDisabled,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
inference = await ollama.callOllama({
|
inference = await ollama.callOllama({
|
||||||
|
|||||||
@@ -39,6 +39,20 @@ export interface CallOpenAiParams {
|
|||||||
* e.g. the ChatMock localhost proxy.
|
* e.g. the ChatMock localhost proxy.
|
||||||
*/
|
*/
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
/**
|
||||||
|
* Forwarded as `max_tokens` when a positive number; omitted otherwise. The
|
||||||
|
* caller is responsible for keeping prompt+max_tokens within the model's
|
||||||
|
* context window (some providers 400 when it exceeds context).
|
||||||
|
*/
|
||||||
|
maxTokens?: number;
|
||||||
|
/**
|
||||||
|
* When true, sends `reasoning: { enabled: false }` to disable OpenRouter
|
||||||
|
* "thinking" tokens — faster/cheaper straight answers, and avoids reasoning
|
||||||
|
* starving the completion budget into a length-truncated (empty) answer.
|
||||||
|
* Only valid for OpenRouter endpoints; leave false for ChatMock / direct
|
||||||
|
* DeepSeek, which don't accept the param.
|
||||||
|
*/
|
||||||
|
disableReasoning?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenAiResult {
|
export interface OpenAiResult {
|
||||||
@@ -68,9 +82,12 @@ function isAbortError(err: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> {
|
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> {
|
||||||
const { baseUrl, model, messages, timeoutMs, apiKey } = params;
|
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning } = params;
|
||||||
const url = `${baseUrl}/v1/chat/completions`;
|
const url = `${baseUrl}/v1/chat/completions`;
|
||||||
const body = JSON.stringify({ model, messages, stream: false });
|
const payload: Record<string, unknown> = { model, messages, stream: false };
|
||||||
|
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||||
|
if (disableReasoning) payload.reasoning = { enabled: false };
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
|
||||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
@@ -187,14 +204,17 @@ export async function callOpenAiStream(
|
|||||||
params: CallOpenAiParams,
|
params: CallOpenAiParams,
|
||||||
onToken: (delta: string) => void,
|
onToken: (delta: string) => void,
|
||||||
): Promise<OpenAiResult> {
|
): Promise<OpenAiResult> {
|
||||||
const { baseUrl, model, messages, timeoutMs, apiKey } = params;
|
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning } = params;
|
||||||
const url = `${baseUrl}/v1/chat/completions`;
|
const url = `${baseUrl}/v1/chat/completions`;
|
||||||
const body = JSON.stringify({
|
const payload: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
stream: true,
|
stream: true,
|
||||||
stream_options: { include_usage: true },
|
stream_options: { include_usage: true },
|
||||||
});
|
};
|
||||||
|
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||||
|
if (disableReasoning) payload.reasoning = { enabled: false };
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
|
|||||||
@@ -856,6 +856,8 @@ function makeChatSessionHandlers(deps: ResolvedDeps) {
|
|||||||
messages: record.transcript,
|
messages: record.transcript,
|
||||||
timeoutMs: deps.config.openaiTimeoutMs,
|
timeoutMs: deps.config.openaiTimeoutMs,
|
||||||
apiKey: deps.config.openaiApiKey,
|
apiKey: deps.config.openaiApiKey,
|
||||||
|
maxTokens: deps.config.openaiMaxTokens,
|
||||||
|
disableReasoning: deps.config.openaiReasoningDisabled,
|
||||||
},
|
},
|
||||||
(delta) => sse({ type: "token", value: delta }),
|
(delta) => sse({ type: "token", value: delta }),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user