Files
agents-marketplace/supplier/src/openai.ts
david b92bff56a7 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>
2026-07-02 16:47:30 +02:00

350 lines
12 KiB
TypeScript

/**
* supplier/src/openai.ts — HTTP client for an OpenAI-compatible /v1/chat/completions
* endpoint. Used by the mainnet supplier when LLM_BACKEND=openai is routed at a
* localhost Codex-OAuth proxy (e.g. ChatMock).
*
* callOpenAi({ baseUrl, model, messages, timeoutMs })
* POST to ${baseUrl}/v1/chat/completions with body { model, messages, stream: false }
* Returns { content, prompt_tokens, completion_tokens, wallclock_ms }
*
* Error reasons:
* "openai_failure" — non-2xx HTTP response, network error, or unexpected fetch failure
* "openai_timeout" — request exceeded timeoutMs (AbortError surfaced from AbortController)
* "openai_malformed" — response body missing choices[0].message.content (or empty)
*
* /v1/chat/completions response shape (OpenAI / ChatMock):
* { choices: [{ index, message: { role: "assistant", content: string }, finish_reason }],
* usage: { prompt_tokens, completion_tokens, total_tokens } }
*
* Implementation notes:
* - Mirrors supplier/src/ollama.ts so jobRunner can branch between backends
* without changing downstream receipt-building code (same return shape).
* - wallclock_ms is measured LOCALLY via Date.now() bracketing fetch — the
* OpenAI response has no total_duration field, so we time the round-trip
* here. Inclusive of network + upstream model + JSON parse.
* - Uses global fetch + AbortController so tests can vi.stubGlobal("fetch").
* - Empty-string content is treated as malformed (matches ollama.ts).
*/
import type { ChatMessage } from "@marketplace/shared/tx";
export interface CallOpenAiParams {
baseUrl: string;
model: string;
messages: ChatMessage[];
timeoutMs: number;
/**
* Optional bearer token. When non-empty, an `authorization: Bearer <apiKey>`
* header is sent. Omit (or pass "") for endpoints that don't require auth,
* e.g. the ChatMock localhost proxy.
*/
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 {
content: string;
prompt_tokens: number;
completion_tokens: number;
wallclock_ms: number;
}
export type OpenAiErrorReason = "openai_failure" | "openai_timeout" | "openai_malformed";
export class OpenAiError extends Error {
public readonly reason: OpenAiErrorReason;
constructor(reason: OpenAiErrorReason, message?: string) {
super(message ?? reason);
this.name = "OpenAiError";
this.reason = reason;
}
}
function isAbortError(err: unknown): boolean {
if (err instanceof Error) {
if (err.name === "AbortError") return true;
if ((err as { code?: string }).code === "ABORT_ERR") return true;
}
return false;
}
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> {
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning } = params;
const url = `${baseUrl}/v1/chat/completions`;
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" };
if (apiKey) {
headers.authorization = `Bearer ${apiKey}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
} catch (err) {
if (isAbortError(err)) {
throw new OpenAiError("openai_timeout", `OpenAI request exceeded ${timeoutMs}ms`);
}
throw new OpenAiError(
"openai_failure",
`OpenAI fetch failed: ${(err as Error)?.message ?? String(err)}`,
);
} finally {
clearTimeout(timer);
}
if (!response.ok) {
let detail = "";
try {
detail = await response.text();
} catch {
// ignore — body unavailable
}
throw new OpenAiError(
"openai_failure",
`OpenAI returned HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
);
}
let parsed: unknown;
try {
parsed = await response.json();
} catch (err) {
throw new OpenAiError(
"openai_malformed",
`OpenAI response was not valid JSON: ${(err as Error)?.message ?? String(err)}`,
);
}
if (!parsed || typeof parsed !== "object") {
throw new OpenAiError("openai_malformed", "OpenAI response was not an object");
}
const obj = parsed as Record<string, unknown>;
const choicesRaw = obj.choices;
if (!Array.isArray(choicesRaw) || choicesRaw.length === 0) {
throw new OpenAiError("openai_malformed", "OpenAI response missing 'choices' array");
}
const firstChoice = choicesRaw[0];
if (!firstChoice || typeof firstChoice !== "object") {
throw new OpenAiError("openai_malformed", "OpenAI response choices[0] not an object");
}
const messageRaw = (firstChoice as Record<string, unknown>).message;
if (!messageRaw || typeof messageRaw !== "object") {
throw new OpenAiError("openai_malformed", "OpenAI response missing 'choices[0].message'");
}
const content = (messageRaw as Record<string, unknown>).content;
if (typeof content !== "string" || content.length === 0) {
throw new OpenAiError(
"openai_malformed",
"OpenAI response missing/empty choices[0].message.content",
);
}
const usageRaw = obj.usage;
let promptTokens = 0;
let completionTokens = 0;
if (usageRaw && typeof usageRaw === "object") {
const usage = usageRaw as Record<string, unknown>;
if (typeof usage.prompt_tokens === "number") promptTokens = usage.prompt_tokens;
if (typeof usage.completion_tokens === "number") completionTokens = usage.completion_tokens;
}
const wallclockMs = Date.now() - startedAt;
return {
content,
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
wallclock_ms: wallclockMs,
};
}
/**
* callOpenAiStream — streaming variant of callOpenAi for the chat-session
* supplier. POSTs with `stream: true` and invokes `onToken(delta)` for each
* content delta as it arrives, then returns the SAME OpenAiResult shape
* (full accumulated content + token usage) so receipt-building code is shared.
*
* Parses the OpenAI/OpenRouter SSE wire format:
* data: {"choices":[{"delta":{"content":"tok"}}]}\n\n
* ...
* data: {"choices":[],"usage":{prompt_tokens,completion_tokens,...}}\n\n (include_usage)
* data: [DONE]\n\n
*
* Error reasons match callOpenAi (openai_failure / openai_timeout / openai_malformed).
* The timeout bounds the WHOLE stream (AbortController), matching the non-stream path.
*/
export async function callOpenAiStream(
params: CallOpenAiParams,
onToken: (delta: string) => void,
): Promise<OpenAiResult> {
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning } = params;
const url = `${baseUrl}/v1/chat/completions`;
const payload: Record<string, unknown> = {
model,
messages,
stream: 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> = {
"content-type": "application/json",
accept: "text/event-stream",
};
if (apiKey) {
headers.authorization = `Bearer ${apiKey}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
if (isAbortError(err)) {
throw new OpenAiError("openai_timeout", `OpenAI request exceeded ${timeoutMs}ms`);
}
throw new OpenAiError(
"openai_failure",
`OpenAI fetch failed: ${(err as Error)?.message ?? String(err)}`,
);
}
if (!response.ok) {
clearTimeout(timer);
let detail = "";
try {
detail = await response.text();
} catch {
// ignore — body unavailable
}
throw new OpenAiError(
"openai_failure",
`OpenAI returned HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
);
}
if (!response.body) {
clearTimeout(timer);
throw new OpenAiError("openai_malformed", "OpenAI streaming response had no body");
}
let accumulated = "";
let promptTokens = 0;
let completionTokens = 0;
let buffer = "";
const handleData = (payload: string): void => {
const trimmed = payload.trim();
if (trimmed.length === 0 || trimmed === "[DONE]") return;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
// Skip non-JSON keepalive/comment frames.
return;
}
if (!parsed || typeof parsed !== "object") return;
const obj = parsed as Record<string, unknown>;
const choices = obj.choices;
if (Array.isArray(choices) && choices.length > 0) {
const delta = (choices[0] as Record<string, unknown>)?.delta as
| Record<string, unknown>
| undefined;
const content = delta?.content;
if (typeof content === "string" && content.length > 0) {
accumulated += content;
onToken(content);
}
}
const usageRaw = obj.usage;
if (usageRaw && typeof usageRaw === "object") {
const usage = usageRaw as Record<string, unknown>;
if (typeof usage.prompt_tokens === "number") promptTokens = usage.prompt_tokens;
if (typeof usage.completion_tokens === "number") completionTokens = usage.completion_tokens;
}
};
try {
const reader = response.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by a blank line.
let sepIdx: number;
while ((sepIdx = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, sepIdx);
buffer = buffer.slice(sepIdx + 2);
for (const line of frame.split("\n")) {
if (line.startsWith("data:")) handleData(line.slice(5));
}
}
}
// Flush any trailing frame without a final blank line.
if (buffer.length > 0) {
for (const line of buffer.split("\n")) {
if (line.startsWith("data:")) handleData(line.slice(5));
}
}
} catch (err) {
if (isAbortError(err)) {
throw new OpenAiError("openai_timeout", `OpenAI stream exceeded ${timeoutMs}ms`);
}
throw new OpenAiError(
"openai_failure",
`OpenAI stream read failed: ${(err as Error)?.message ?? String(err)}`,
);
} finally {
clearTimeout(timer);
}
if (accumulated.length === 0) {
throw new OpenAiError("openai_malformed", "OpenAI stream produced no content");
}
return {
content: accumulated,
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
wallclock_ms: Date.now() - startedAt,
};
}