Files
openclaw/packages/ai/src/model-utils.ts
Peter Steinberger 4b751ce48a feat(tooling): complete packages/* noUncheckedIndexedAccess adoption (phase 2) (#104626)
* feat(tooling): extend strict-ratchet lane to all remaining leaf packages

* fix(packages): burn down indexed-access debt in ai and agent-core

All 196 noUncheckedIndexedAccess errors fixed behavior-identically
(iteration, .at()/slices, DataView byte math, guarded queue peeks) plus
cleanup finds: a sparse-tool diagnostic bug, deduplicated Responses
call/item ID parsing with an honest string|undefined splitter, and a
clearer local failure for redacted-thinking blocks missing signatures.
Removes all 17 remaining non-null assertions across ai, gateway-client,
and llm-core so the assertion ban aligns 1:1 with the ratchet lane.
speech-core joins memory-host-sdk as structurally deferred (imports
src/** via plugin-sdk path mappings).
2026-07-11 11:54:06 -07:00

117 lines
4.0 KiB
TypeScript

// Provides model selection, usage, and thinking-level utility helpers.
import {
resolveClaudeNativeThinkingLevelMap,
requiresClaudeMandatoryAdaptiveThinking,
} from "@openclaw/llm-core";
import type { Api, Model, ModelThinkingLevel, Usage } from "./types.js";
/** Calculates and stores model cost fields from token usage and per-million pricing. */
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
usage.cost.input = (model.cost.input / 1000000) * usage.input;
usage.cost.output = (model.cost.output / 1000000) * usage.output;
usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead;
usage.cost.cacheWrite = (model.cost.cacheWrite / 1000000) * usage.cacheWrite;
usage.cost.total =
usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
return usage.cost;
}
/** Replaces the catalog estimate when the provider reports an authoritative billed total. */
export function applyProviderReportedUsageCost(usage: Usage, reportedCost: unknown): void {
if (typeof reportedCost !== "number" || !Number.isFinite(reportedCost) || reportedCost < 0) {
return;
}
usage.cost.total = reportedCost;
usage.cost.totalOrigin = "provider-billed";
}
const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
];
function resolveThinkingLevelMap<TApi extends Api>(model: Model<TApi>) {
return model.api === "anthropic-messages"
? (resolveClaudeNativeThinkingLevelMap(model) ?? model.thinkingLevelMap)
: model.thinkingLevelMap;
}
/** Returns thinking levels exposed by a reasoning-capable model. */
export function getSupportedThinkingLevels<TApi extends Api>(
model: Model<TApi>,
): ModelThinkingLevel[] {
const mandatoryAdaptiveContract =
model.api === "anthropic-messages" && requiresClaudeMandatoryAdaptiveThinking(model);
if (!model.reasoning && !mandatoryAdaptiveContract) {
return ["off"];
}
const thinkingLevelMap = resolveThinkingLevelMap(model);
return EXTENDED_THINKING_LEVELS.filter((level) => {
const mapped = thinkingLevelMap?.[level];
if (mapped === null) {
return false;
}
if (level === "xhigh" || level === "max") {
return mapped !== undefined;
}
return true;
});
}
/** Clamps a requested thinking level to the closest supported level for a model. */
export function clampThinkingLevel<TApi extends Api>(
model: Model<TApi>,
level: ModelThinkingLevel,
): ModelThinkingLevel {
const availableLevels = getSupportedThinkingLevels(model);
if (availableLevels.includes(level)) {
return level;
}
const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level);
if (requestedIndex === -1) {
return availableLevels[0] ?? "off";
}
// Explicit provider opt-outs are hard caps. Downgrade them before considering
// stronger levels so unsupported xhigh/max requests cannot increase cost.
const thinkingLevelMap = resolveThinkingLevelMap(model);
if ((level === "xhigh" || level === "max") && thinkingLevelMap?.[level] === null) {
for (const candidate of EXTENDED_THINKING_LEVELS.slice(0, requestedIndex).toReversed()) {
if (availableLevels.includes(candidate)) {
return candidate;
}
}
}
// Prefer the next stronger available level, then walk down if the request was above the model cap.
for (const candidate of EXTENDED_THINKING_LEVELS.slice(requestedIndex)) {
if (availableLevels.includes(candidate)) {
return candidate;
}
}
for (const candidate of EXTENDED_THINKING_LEVELS.slice(0, requestedIndex).toReversed()) {
if (availableLevels.includes(candidate)) {
return candidate;
}
}
return availableLevels[0] ?? "off";
}
/** Compares model identity by provider and id. */
export function modelsAreEqual<TApi extends Api>(
a: Model<TApi> | null | undefined,
b: Model<TApi> | null | undefined,
): boolean {
if (!a || !b) {
return false;
}
return a.id === b.id && a.provider === b.provider;
}