mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
The nightly briefing warm completed every night and read "all-failed", because one run event held every account's annotations and the last account won. Each candidate now runs in its own wide event and the run reports every outcome through `reportJobRun`, so ok, dead sign-in, withheld and failed accounts show side by side. A dead Codex refresh token threw a bare error with no status, so the chain read it as a network failure: the health ledger recorded hard_failed, no "reconnect" hint appeared, and every night tried the same token. The refresh now throws `CodexRefreshError` with a 401 for a dead grant (invalid_grant / invalid_refresh_token on a 400 included), as do a missing or undecryptable stored credential. The ledger records auth_failed, the chain marks the primary credential expired, and the nightly pass reports the account as auth_failed without queuing a retry. A briefing the outbound screen withheld (a causal claim) failed outright, and the 45-minute provider retry then asked the same question eight times a night. The generator now makes one bounded repair that names the broken rule and keeps the repaired payload only if it passes the same parse, grounding and screen; a withheld outcome queues no retry. A provider that keeps failing is paused: from five consecutive hard failures the skip window starts at 30 minutes and doubles per further failure, capped at a day. A paused provider is walked last and tried again when the window lifts; one success resumes it. The operator card shows how many accounts it is paused for and until when. After a 5xx from a non-canonical OpenAI-compatible endpoint the client asks the free model listing, and a configured model the endpoint no longer offers is named in the error, on the hop line and on the card. Also: - The tiered context's live day band read `measured_at AT TIME ZONE tz`, which treats the stored UTC as local time; a far-from-UTC reader's evening readings landed on the next day. It converts from UTC first now. - Google Health intraday heart rate re-reads two hours per sync instead of a day; daily summaries and sleep keep the day (#1023). - The first filtered daily read that answers 400 and triggers the snake_case retry is recorded as a note, not an error (#1023). - The single derived-score read goes through the derived cache with stale-while-revalidate, like the batch beside it.
591 lines
25 KiB
TypeScript
591 lines
25 KiB
TypeScript
import { safeFetch } from "@/lib/safe-fetch";
|
|
import { callTimeoutMs } from "./effective-timeout";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { emitSignal } from "@/lib/logging/signal";
|
|
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
|
import {
|
|
isCanonicalOpenAIEndpoint,
|
|
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 { probeModelListing } from "./model-availability";
|
|
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";
|
|
}
|
|
|
|
/**
|
|
* v1.42 — after a server error from an operator- or person-configured
|
|
* endpoint (never the canonical `api.openai.com`, which names a missing
|
|
* model in its own 404), check whether it still lists the configured
|
|
* model. A 4xx already carries its reason in the body; a 5xx from a proxy
|
|
* often carries none. See `model-availability.ts`.
|
|
*/
|
|
private async modelNoLongerServed(status: number): Promise<boolean> {
|
|
if (status < 500) return false;
|
|
if (this.type === "codex") return false;
|
|
if (isCanonicalOpenAIEndpoint(this.config.baseUrl)) return false;
|
|
const listing = await probeModelListing({
|
|
baseUrl: this.config.baseUrl,
|
|
apiKey: this.config.apiKey,
|
|
model: this.config.model,
|
|
operatorTrusted: this.config.operatorTrusted,
|
|
});
|
|
if (listing === "not_listed") {
|
|
emitSignal({
|
|
action: "ai.provider.model_not_served",
|
|
level: "error",
|
|
meta: { provider: this.type, model: this.config.model, status },
|
|
});
|
|
}
|
|
return listing === "not_listed";
|
|
}
|
|
|
|
/**
|
|
* 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 } : {}),
|
|
// v1.41.2 — the prompt-cache routing key `api.openai.com` documents
|
|
// (https://platform.openai.com/docs/guides/prompt-caching). Only
|
|
// there: a gateway or proxy behind another base URL may refuse a
|
|
// field it does not know.
|
|
...(params.cacheKey && isCanonicalOpenAIEndpoint(this.config.baseUrl)
|
|
? { prompt_cache_key: params.cacheKey }
|
|
: {}),
|
|
// #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);
|
|
}
|
|
// v1.42 — a proxy that stopped offering the configured model answers
|
|
// with a bare 500 that names nothing. Ask its model listing (free, no
|
|
// completion) and say so plainly when the name is gone, so the chain,
|
|
// the health card and the log name the actual fix.
|
|
const modelNotServed = await this.modelNoLongerServed(res.status);
|
|
const err = new Error(
|
|
modelNotServed
|
|
? `${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} request failed (${res.status}): model "${this.config.model}" is not offered by this endpoint`
|
|
: `${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,
|
|
...(modelNotServed ? { modelNotServed: true } : {}),
|
|
});
|
|
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;
|
|
}
|
|
}
|