mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
The Coach loop's final round sent toolChoice "none" without the tool definitions. With tool_use/tool_result blocks in the history Anthropic refuses such a request (400), and OpenAI Chat Completions refuses tool_choice without tools, so every budget, time, cap and no-progress stop ended in an error on those wires. The final round now carries the tools with toolChoice "none", which every wire accepts, thinking included. Codex kept reasoning items as provider state even when the request asked for no encrypted reasoning; replaying such an item under store:false is a 400. Items are kept only when the request included reasoning.encrypted_content and the item carries it. The reasoning dialect cache learned from any 4xx that mentioned reasoning or thinking (auth, rate limits, model names such as deepseek-reasoner) and from replay errors, and kept the downgrade for the life of the process. Learning now requires an HTTP 400 that refuses a sent parameter, ignores replay errors and the model name, and a learned downgrade expires after an hour.
546 lines
23 KiB
TypeScript
546 lines
23 KiB
TypeScript
import { safeFetch } from "@/lib/safe-fetch";
|
|
import { callTimeoutMs } from "./effective-timeout";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
|
import {
|
|
selectOpenAIChatCompletionsCapabilities,
|
|
type OpenAIChatCompletionsCapabilities,
|
|
} from "./openai-capabilities";
|
|
import {
|
|
annotateReasoningDowngrade,
|
|
isReasoningParameterRejection,
|
|
learnedReasoningDialect,
|
|
rememberReasoningDialect,
|
|
} from "./reasoning/dialect-cache";
|
|
import { REASONING_THINKING_BUDGET } from "./reasoning/levels";
|
|
import {
|
|
isOpenRouterEndpoint,
|
|
levelOfWireEffort,
|
|
openAIOffEffort,
|
|
openAIReasoningFamily,
|
|
reasoningTitleOf,
|
|
} from "./reasoning/support";
|
|
import {
|
|
hasLearnedJsonModeDialect,
|
|
isResponseFormatRejection,
|
|
jsonModeDialectFor,
|
|
rememberJsonModeDialect,
|
|
} from "./json-dialect";
|
|
import { aiEgressPolicyFor } from "./local-host-allowlist";
|
|
import type { ReasoningEffort } from "./reasoning-effort";
|
|
import {
|
|
buildOpenAIMessages,
|
|
buildOpenAITools,
|
|
mapFinishReason,
|
|
parseCachedTokens,
|
|
parseOpenAIToolCalls,
|
|
raiseEmbeddedError,
|
|
sanitiseBodyExcerpt,
|
|
type OpenAIResponseJson,
|
|
} from "./openai-wire";
|
|
|
|
interface OpenAIClientConfig {
|
|
/**
|
|
* Bearer credential. Empty string means "no bearer" — only the
|
|
* `openai-compatible` tag ever constructs that way (a LAN gateway with no
|
|
* auth); every OpenAI-keyed call site checks the key's presence first.
|
|
*/
|
|
apiKey: string;
|
|
model: string;
|
|
baseUrl: string;
|
|
/**
|
|
* Override the provider tag used in logs and analytics. Defaults to
|
|
* "admin-key". The Codex flow uses the same OpenAI API but the key
|
|
* was obtained via the token-exchange grant against a ChatGPT
|
|
* subscription — for billing and observability we want that path
|
|
* to log as "codex" instead.
|
|
*
|
|
* v1.33.1 (#470) — `openai-compatible` is the user-configured gateway
|
|
* (LiteLLM / OpenRouter / vLLM). Its `baseUrl` always comes from a person,
|
|
* and two behaviours hang off that: the operator's private-host allowlist
|
|
* may apply, and the JSON-mode dialect is learned per endpoint instead of
|
|
* assumed. `admin-key` carries a person-typed base URL in exactly one
|
|
* place (the operator's global provider in the admin settings) and shares
|
|
* the allowlist policy for it since v1.37.30; the dialect stays assumed.
|
|
* `codex` keeps the fully pinned posture.
|
|
*/
|
|
providerType?: OpenAIProviderType;
|
|
/**
|
|
* v1.39.3 — true when the base URL belongs to the operator: the
|
|
* instance-wide admin provider, or provider settings saved on an admin
|
|
* account. Only such a URL is still covered by the deprecated
|
|
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` (see `local-host-allowlist.ts`).
|
|
*/
|
|
operatorTrusted?: boolean;
|
|
}
|
|
|
|
type OpenAIProviderType = "admin-key" | "codex" | "openai-compatible";
|
|
|
|
/**
|
|
* v1.41 — the reasoning one request sends.
|
|
* - `reasoning_effort`: the Chat Completions field (OpenAI, LiteLLM, vLLM,
|
|
* most gateways). https://platform.openai.com/docs/api-reference/chat/create
|
|
* - `openrouter`: OpenRouter's `reasoning: { effort }` object, which also
|
|
* returns `reasoning_details` to hand back on tool rounds.
|
|
* https://openrouter.ai/docs/use-cases/reasoning-tokens
|
|
*/
|
|
type OpenAIReasoningWire =
|
|
| { kind: "none" }
|
|
| { kind: "reasoning_effort"; effort: string }
|
|
| { kind: "openrouter"; effort: string };
|
|
|
|
/**
|
|
* Assistant-message fields a gateway returns and needs back unmodified on the
|
|
* next tool round: OpenRouter's `reasoning_details` and the `thinking_blocks`
|
|
* LiteLLM carries for Claude upstreams (https://docs.litellm.ai/docs/reasoning_content).
|
|
*/
|
|
const ROUND_TRIP_FIELDS = ["reasoning_details", "thinking_blocks"] as const;
|
|
|
|
export class OpenAIClient implements AIProvider {
|
|
readonly type: OpenAIProviderType;
|
|
/** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
|
|
responseTimeoutSeconds: number | null = null;
|
|
/**
|
|
* See `AIProvider.reasoningEffort`; stamped by the resolver. Only the
|
|
* gateway tag ever sends it (`isGateway` below), whatever is stamped here.
|
|
*/
|
|
reasoningEffort: ReasoningEffort | null = null;
|
|
private config: OpenAIClientConfig;
|
|
|
|
constructor(config: OpenAIClientConfig) {
|
|
this.config = config;
|
|
this.type = config.providerType ?? "admin-key";
|
|
}
|
|
|
|
/**
|
|
* True only for the user-configured gateway. Read it as "the base URL came
|
|
* from a person, not from this repository" — every behaviour that must not
|
|
* change for `api.openai.com` is gated on it.
|
|
*/
|
|
private get isGateway(): boolean {
|
|
return this.type === "openai-compatible";
|
|
}
|
|
|
|
async generateCompletion(
|
|
params: CompletionParams,
|
|
): Promise<CompletionResult> {
|
|
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
|
|
// System turn first (the stable cache prefix — OpenAI prefix-caches a
|
|
// byte-identical ≥1024-token prefix automatically, no API flag), then the
|
|
// conversation turns mapped 1:1. Vision parts (Lab-OCR) become the
|
|
// multimodal `image_url` content array `gpt-4o`-class models accept. The
|
|
// image is framed as untrusted DATA by the system prompt.
|
|
const messages = this.withRoundTripState(
|
|
buildOpenAIMessages(params.system, params.messages),
|
|
params,
|
|
);
|
|
|
|
// v1.20.0 — tool plumbing. The defs map onto the OpenAI `function` tool
|
|
// wire; F1 supplies real defs and consumes the parsed `toolCalls`. No F4
|
|
// call site sets `tools`, so the text-only body is unchanged.
|
|
const hasTools = !!params.tools && params.tools.length > 0;
|
|
const tools = hasTools ? buildOpenAITools(params.tools!) : undefined;
|
|
|
|
// Only force OpenAI's strict JSON mode when the caller actually consumes a
|
|
// JSON object AND no tools are in play — mirrors the Anthropic client's
|
|
// `usePrefill` gate. JSON mode coerces `message.content` into a valid JSON
|
|
// object, which contradicts the Coach prose contract; the tool loop's
|
|
// forced-final round (tools with toolChoice:"none", no responseFormat)
|
|
// must therefore stay out of JSON mode, and every tool round is non-JSON
|
|
// by construction. Insight/extraction callers opt in with `responseFormat:"json"`.
|
|
const useJsonFormat = params.responseFormat === "json" && !hasTools;
|
|
// v1.33.1 (#470) — a gateway may reject the standard `response_format`
|
|
// field. Learn that per endpoint (shared cache with the Local client) and
|
|
// retry once without the flag below. `api.openai.com` defines the field,
|
|
// so the pinned tags never consult the dialect and never retry.
|
|
const jsonDialect = this.isGateway
|
|
? jsonModeDialectFor(this.config.baseUrl)
|
|
: "response_format";
|
|
const sendJsonFormat = useJsonFormat && jsonDialect === "response_format";
|
|
const capabilities = selectOpenAIChatCompletionsCapabilities(
|
|
this.config.baseUrl,
|
|
this.config.model,
|
|
);
|
|
const reasoningWire = this.planReasoning(params, hasTools, capabilities);
|
|
// Reasoning tokens count against the completion budget on every one of
|
|
// these wires, so the thinking budget rides on top of the answer budget
|
|
// (a ceiling, not a charge). Without it a 600-token Coach round spends
|
|
// its whole allowance thinking and returns no answer.
|
|
const wireLevel =
|
|
reasoningWire.kind === "none" || reasoningWire.effort === "none"
|
|
? null
|
|
: levelOfWireEffort(reasoningWire.effort);
|
|
const thinkingTokens = wireLevel
|
|
? REASONING_THINKING_BUDGET[wireLevel === "off" ? "low" : wireLevel]
|
|
: 0;
|
|
const tokenBudget = (params.maxTokens ?? 1000) + thinkingTokens;
|
|
const capabilityParams = capabilities.supportsSamplingControls
|
|
? {
|
|
[capabilities.tokenBudgetField]: tokenBudget,
|
|
temperature: params.temperature ?? 0.3,
|
|
...(params.seed !== undefined ? { seed: params.seed } : {}),
|
|
}
|
|
: { [capabilities.tokenBudgetField]: tokenBudget };
|
|
|
|
// A gateway that needs no bearer (a LAN LiteLLM without a master key)
|
|
// gets no `Authorization` header at all rather than an empty one — an
|
|
// empty bearer is a 401 on some proxies. Every OpenAI-keyed construction
|
|
// site checks the key's presence, so this branch is gateway-only in
|
|
// practice.
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
const trimmedKey = this.config.apiKey?.trim();
|
|
if (trimmedKey) headers.Authorization = `Bearer ${trimmedKey}`;
|
|
|
|
const res = await safeFetch(
|
|
url,
|
|
{
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
model: this.config.model,
|
|
messages,
|
|
...capabilityParams,
|
|
...(sendJsonFormat
|
|
? { response_format: { type: "json_object" } }
|
|
: {}),
|
|
...(tools ? { tools } : {}),
|
|
...(params.toolChoice ? { tool_choice: params.toolChoice } : {}),
|
|
// #1126 — the gateway entry's reasoning setting; Default sends
|
|
// nothing. `api.openai.com` and Codex never get it. v1.41 — a call
|
|
// that carries its own resolved level (`params.reasoning`) wins.
|
|
...(!params.reasoning && this.isGateway && this.reasoningEffort
|
|
? { reasoning_effort: this.reasoningEffort }
|
|
: {}),
|
|
...(reasoningWire.kind === "reasoning_effort"
|
|
? { reasoning_effort: reasoningWire.effort }
|
|
: reasoningWire.kind === "openrouter"
|
|
? { reasoning: { effort: reasoningWire.effort } }
|
|
: {}),
|
|
}),
|
|
},
|
|
// 60 s ceiling so a tar-pit upstream cannot pin a worker
|
|
// indefinitely. Real completions land well inside this budget.
|
|
// v1.11.2 — the base URL is user/admin-overridable (BYO gateway), so pin
|
|
// the connect-time DNS check: a base URL resolving to a private/metadata
|
|
// address is rejected, closing the SSRF/rebinding surface.
|
|
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
|
|
// a mid-generation abort tears the upstream call down early.
|
|
// The record owner's response-timeout setting, else the surface value, else 60 s.
|
|
// v1.33.1 (#470) — the gateway tag reuses the Local provider's host
|
|
// policy verbatim: public hosts always; a private host only when the
|
|
// operator granted its origin (`AI_PRIVATE_ORIGINS` or the legacy host
|
|
// list), and then only through the pinned operator-approved dispatcher.
|
|
//
|
|
// v1.37.30 — the `admin-key` tag consults the same policy. Its base URL
|
|
// is the one other operator-typed URL in this client (an operator can
|
|
// point the global provider at a private OpenAI-compatible proxy, e.g.
|
|
// an OAuth sidecar), and both ends of the grant are the operator's own:
|
|
// the URL comes from the admin settings, the allowlist from the
|
|
// operator's environment. Every non-operator construction of this tag
|
|
// pins the public `api.openai.com` constant, for which the policy
|
|
// resolves to `true` without ever consulting the allowlist — so the
|
|
// personal-key posture is unchanged. The `codex` tag keeps the hard
|
|
// pin: its base URL is a repository constant, there is nothing an
|
|
// operator legitimately redirects.
|
|
{
|
|
timeoutMs: callTimeoutMs(params, this.responseTimeoutSeconds),
|
|
...(this.isGateway || this.type === "admin-key"
|
|
? aiEgressPolicyFor(url, {
|
|
operatorTrusted: this.config.operatorTrusted,
|
|
})
|
|
: { requirePublicHost: true as const }),
|
|
signal: params.signal,
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
// Pull as much of the body as we can so upstream incidents (OpenAI 5xx,
|
|
// model-not-found, quota exceeded) are diagnosable from logs instead of
|
|
// surfacing as an opaque "OpenAI request failed (500)". Strip anything
|
|
// that looks like an API key from the excerpt before logging.
|
|
const rawBody = await res.text().catch(() => "");
|
|
const bodyExcerpt = sanitiseBodyExcerpt(rawBody);
|
|
// v1.41 — an endpoint or model that refuses the reasoning field is
|
|
// asked once more without it and remembers, so the answer never fails
|
|
// on a reasoning parameter. Checked before the JSON dialect: a body
|
|
// naming the reasoning field must not switch JSON mode off.
|
|
if (
|
|
reasoningWire.kind !== "none" &&
|
|
isReasoningParameterRejection(
|
|
res.status,
|
|
bodyExcerpt,
|
|
/reasoning|thinking/i,
|
|
this.config.model,
|
|
)
|
|
) {
|
|
rememberReasoningDialect(this.reasoningDialectKey(), "none");
|
|
annotateReasoningDowngrade(this.type, reasoningWire.effort, "none");
|
|
return this.generateCompletion(params);
|
|
}
|
|
// v1.33.1 (#470) — dialect self-heal, gateway only. A 4xx whose body
|
|
// names `response_format` (or an unknown parameter) means this endpoint
|
|
// rejects the standard JSON flag: learn the no-flag dialect for the base
|
|
// URL and retry ONCE without it. The recursion terminates because the
|
|
// cached dialect is now "none". Unrelated 4xx/5xx errors are not retried
|
|
// and surface as the structured error they always were.
|
|
if (
|
|
this.isGateway &&
|
|
sendJsonFormat &&
|
|
isResponseFormatRejection(res.status, bodyExcerpt)
|
|
) {
|
|
rememberJsonModeDialect(this.config.baseUrl, "none");
|
|
annotate({
|
|
action: { name: "ai.compat.jsonDialect" },
|
|
meta: { dialect: "none", httpStatus: res.status },
|
|
});
|
|
return this.generateCompletion(params);
|
|
}
|
|
const err = new Error(
|
|
`${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} request failed (${res.status})`,
|
|
);
|
|
// Keep the body in a structured field rather than the message so
|
|
// Error.message stays short and the excerpt lands in dedicated log
|
|
// fields (bodyExcerpt) that can be filtered/truncated centrally.
|
|
Object.assign(err, {
|
|
httpStatus: res.status,
|
|
upstream: this.isGateway ? "openai-compatible" : "openai",
|
|
model: this.config.model,
|
|
bodyExcerpt,
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
// v1.33.1 (#470) — the standard `response_format` request succeeded: pin
|
|
// the dialect for this endpoint so a later transient 4xx can never
|
|
// silently degrade it.
|
|
if (
|
|
this.isGateway &&
|
|
sendJsonFormat &&
|
|
!hasLearnedJsonModeDialect(this.config.baseUrl)
|
|
) {
|
|
rememberJsonModeDialect(this.config.baseUrl, "response_format");
|
|
annotate({
|
|
action: { name: "ai.compat.jsonDialect" },
|
|
meta: { dialect: "response_format" },
|
|
});
|
|
}
|
|
|
|
const json = (await res.json()) as OpenAIResponseJson;
|
|
|
|
// A 200 that carries the failure inside the body. OpenRouter (and every
|
|
// gateway that copies it) answers a provider-side refusal — no credits,
|
|
// a data-policy or moderation block, an upstream timeout, a model that is
|
|
// down, no provider matching the routing requirements — with HTTP 200 and
|
|
// a top-level `error` object instead of `choices`, and a generation that
|
|
// died mid-flight with `choices[0].error` beside `finish_reason: "error"`.
|
|
// Both used to fall through to the empty-content throw below, so every one
|
|
// of those causes collapsed into "returned empty content", status 0, no
|
|
// excerpt: neither the person nor the provider-health card could tell them
|
|
// apart. Raise the same shaped error a non-2xx reply raises so the chain
|
|
// classifier, the health ledger and the card all see the real status.
|
|
//
|
|
// Gated on the gateway tag because the shape is gateway-only: on
|
|
// `api.openai.com` — the `admin-key` and `codex` tags — a failure is
|
|
// always a non-2xx status, so their parsing is untouched.
|
|
if (this.isGateway) {
|
|
raiseEmbeddedError(json, {
|
|
upstream: "openai-compatible",
|
|
label: "OpenAI-compatible gateway",
|
|
model: this.config.model,
|
|
});
|
|
}
|
|
|
|
const choice = json.choices?.[0];
|
|
let content = choice?.message?.content;
|
|
const toolCalls = parseOpenAIToolCalls(choice);
|
|
|
|
// v1.28.28 (#470) — gateway JSON-mode shim. LiteLLM (and similar
|
|
// OpenAI-compatible proxies) translate `response_format: json_object`
|
|
// for an Anthropic upstream into a synthesized tool call: the reply
|
|
// comes back with `finish_reason: "tool_calls"`, an empty / "{}"
|
|
// `message.content`, and the actual JSON object in
|
|
// `tool_calls[0].function.arguments`. Since no JSON caller passes
|
|
// tools (useJsonFormat requires !hasTools), lift the arguments string
|
|
// into `content` so the payload parses instead of silently emptying
|
|
// the insight. The real tool loop (Coach F1) always has tools and
|
|
// never enters JSON mode, so it is untouched.
|
|
const trimmedContent = content?.trim() ?? "";
|
|
if (useJsonFormat && (trimmedContent === "" || trimmedContent === "{}")) {
|
|
const args = choice?.message?.tool_calls?.[0]?.function?.arguments;
|
|
if (typeof args === "string" && args.trim().length > 0) {
|
|
content = args;
|
|
}
|
|
}
|
|
|
|
// A reply with tool calls and no prose is valid (F1 tool loop); only an
|
|
// empty reply with neither content NOR tool calls is an error.
|
|
if (!content && !toolCalls) {
|
|
// v1.20.1 — tag the empty-reply throw with a sentinel `httpStatus: 0` +
|
|
// `kind` so the provider-chain classifier can tell an empty 200-OK reply
|
|
// apart from a genuine transport failure (ECONNRESET, DNS, timeout) in
|
|
// observability. The cascade is unchanged: `isHardProviderFailure`
|
|
// already treats `status <= 0` as a hard failure and walks to the next
|
|
// provider.
|
|
const err = new Error(
|
|
`${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} returned empty content`,
|
|
);
|
|
Object.assign(err, {
|
|
httpStatus: 0,
|
|
kind: "empty_response",
|
|
upstream: this.isGateway ? "openai-compatible" : "openai",
|
|
});
|
|
throw err;
|
|
}
|
|
|
|
// v1.41 — what the call reasoned. Gateways return the text as
|
|
// `reasoning` (OpenRouter, vLLM, Ollama) or `reasoning_content`
|
|
// (LiteLLM, llama.cpp); `api.openai.com` returns none on this wire.
|
|
const message = (choice?.message ?? {}) as Record<string, unknown>;
|
|
const reasoningText = [message.reasoning, message.reasoning_content].find(
|
|
(v): v is string => typeof v === "string" && v.trim().length > 0,
|
|
);
|
|
const summary = reasoningText ? [reasoningText.trim()] : [];
|
|
const emit = params.reasoning ? params.onReasoning : undefined;
|
|
if (emit) {
|
|
for (const text of summary) {
|
|
const title = reasoningTitleOf(text);
|
|
if (title) emit({ kind: "title", text: title });
|
|
emit({ kind: "text", text });
|
|
}
|
|
emit({ kind: "done", text: "" });
|
|
}
|
|
const roundTrip = ROUND_TRIP_FIELDS.filter(
|
|
(field) => message[field] !== undefined && message[field] !== null,
|
|
).map((field) => ({ [field]: message[field] }));
|
|
const usage = json.usage as
|
|
{ completion_tokens_details?: { reasoning_tokens?: number } } | undefined;
|
|
const learnedNone =
|
|
learnedReasoningDialect(this.reasoningDialectKey()) === "none";
|
|
|
|
return {
|
|
content: content ?? "",
|
|
tokensUsed: json.usage?.total_tokens ?? null,
|
|
cachedInputTokens: parseCachedTokens(json),
|
|
model: this.config.model,
|
|
providerType: this.type,
|
|
...(toolCalls ? { toolCalls } : {}),
|
|
finishReason: mapFinishReason(choice?.finish_reason),
|
|
...(params.reasoning
|
|
? {
|
|
reasoning: {
|
|
summary,
|
|
// Billed as completion tokens, already inside `total_tokens`.
|
|
tokens:
|
|
usage?.completion_tokens_details?.reasoning_tokens ?? null,
|
|
...(learnedNone && params.reasoning.effort !== "off"
|
|
? { downgradedTo: "off" as const }
|
|
: {}),
|
|
},
|
|
}
|
|
: {}),
|
|
...(roundTrip.length > 0
|
|
? {
|
|
providerState: {
|
|
providerType: this.type,
|
|
model: this.config.model,
|
|
items: roundTrip,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
// ── v1.41 reasoning ───────────────────────────────────────────────────────
|
|
|
|
private reasoningDialectKey() {
|
|
return {
|
|
provider: "openai",
|
|
endpoint: this.config.baseUrl,
|
|
model: this.config.model,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The reasoning this request sends. Absent `params.reasoning` → none here
|
|
* (the #1126 gateway setting is applied separately, unchanged).
|
|
*
|
|
* `api.openai.com` (operator key, personal key): `reasoning_effort` only for
|
|
* a reasoning model, and only on a round without tools. Chat Completions
|
|
* has no reasoning items to hand back between tool rounds, and newer
|
|
* models restrict reasoning alongside function calls. The background jobs
|
|
* carry no tools, which is where it counts; the Coach's final round
|
|
* carries its tools with `tool_choice: "none"` (the history holds the
|
|
* calls), so here it runs without `reasoning_effort` like its tool rounds.
|
|
*
|
|
* Gateway: the endpoint's own dialect, tools or not; its reasoning state
|
|
* travels in `providerState`, and a refusal is learned per endpoint.
|
|
*/
|
|
private planReasoning(
|
|
params: CompletionParams,
|
|
hasTools: boolean,
|
|
capabilities: OpenAIChatCompletionsCapabilities,
|
|
): OpenAIReasoningWire {
|
|
const reasoning = params.reasoning;
|
|
if (!reasoning) return { kind: "none" };
|
|
if (learnedReasoningDialect(this.reasoningDialectKey()) === "none") {
|
|
return { kind: "none" };
|
|
}
|
|
const level = reasoning.effort;
|
|
if (this.isGateway) {
|
|
const effort = level === "off" ? "none" : level;
|
|
return isOpenRouterEndpoint(this.config.baseUrl)
|
|
? { kind: "openrouter", effort }
|
|
: { kind: "reasoning_effort", effort };
|
|
}
|
|
const family = openAIReasoningFamily(this.config.model);
|
|
if (
|
|
!family ||
|
|
hasTools ||
|
|
capabilities.tokenBudgetField !== "max_completion_tokens"
|
|
) {
|
|
return { kind: "none" };
|
|
}
|
|
return {
|
|
kind: "reasoning_effort",
|
|
effort: level === "off" ? openAIOffEffort(family) : level,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Put the reasoning state of earlier tool rounds back on their assistant
|
|
* messages. `buildOpenAIMessages` maps turn `i` to wire message `i + 1`
|
|
* (the system turn leads). Only state this client made for this model is
|
|
* used; anything else is dropped.
|
|
*/
|
|
private withRoundTripState<T extends object>(
|
|
wire: T[],
|
|
params: CompletionParams,
|
|
): T[] {
|
|
params.messages.forEach((m, i) => {
|
|
const state = m.providerState;
|
|
if (
|
|
m.role !== "assistant" ||
|
|
!state ||
|
|
state.providerType !== this.type ||
|
|
state.model !== this.config.model
|
|
) {
|
|
return;
|
|
}
|
|
const target = wire[i + 1] as Record<string, unknown> | undefined;
|
|
if (!target) return;
|
|
for (const item of state.items) {
|
|
if (!item || typeof item !== "object") continue;
|
|
for (const field of ROUND_TRIP_FIELDS) {
|
|
if (field in item) {
|
|
target[field] = (item as Record<string, unknown>)[field];
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return wire;
|
|
}
|
|
}
|