mirror of https://github.com/openclaw/openclaw.git
217 lines
6.7 KiB
TypeScript
217 lines
6.7 KiB
TypeScript
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
|
import type { SimpleStreamOptions } from "@mariozechner/pi-ai";
|
|
import { streamSimple } from "@mariozechner/pi-ai";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import { log } from "./logger.js";
|
|
|
|
const OPENROUTER_APP_HEADERS: Record<string, string> = {
|
|
"HTTP-Referer": "https://openclaw.ai",
|
|
"X-Title": "OpenClaw",
|
|
};
|
|
// NOTE: We only force `store=true` for *direct* OpenAI Responses.
|
|
// Codex responses (chatgpt.com/backend-api/codex/responses) require `store=false`.
|
|
const OPENAI_RESPONSES_APIS = new Set(["openai-responses"]);
|
|
const OPENAI_RESPONSES_PROVIDERS = new Set(["openai"]);
|
|
|
|
/**
|
|
* Resolve provider-specific extra params from model config.
|
|
* Used to pass through stream params like temperature/maxTokens.
|
|
*
|
|
* @internal Exported for testing only
|
|
*/
|
|
export function resolveExtraParams(params: {
|
|
cfg: OpenClawConfig | undefined;
|
|
provider: string;
|
|
modelId: string;
|
|
}): Record<string, unknown> | undefined {
|
|
const modelKey = `${params.provider}/${params.modelId}`;
|
|
const modelConfig = params.cfg?.agents?.defaults?.models?.[modelKey];
|
|
return modelConfig?.params ? { ...modelConfig.params } : undefined;
|
|
}
|
|
|
|
type CacheRetention = "none" | "short" | "long";
|
|
type CacheRetentionStreamOptions = Partial<SimpleStreamOptions> & {
|
|
cacheRetention?: CacheRetention;
|
|
};
|
|
|
|
/**
|
|
* Resolve cacheRetention from extraParams, supporting both new `cacheRetention`
|
|
* and legacy `cacheControlTtl` values for backwards compatibility.
|
|
*
|
|
* Mapping: "5m" → "short", "1h" → "long"
|
|
*
|
|
* Only applies to Anthropic provider (OpenRouter uses openai-completions API
|
|
* with hardcoded cache_control, not the cacheRetention stream option).
|
|
*/
|
|
function resolveCacheRetention(
|
|
extraParams: Record<string, unknown> | undefined,
|
|
provider: string,
|
|
): CacheRetention | undefined {
|
|
if (provider !== "anthropic") {
|
|
return undefined;
|
|
}
|
|
|
|
// Prefer new cacheRetention if present
|
|
const newVal = extraParams?.cacheRetention;
|
|
if (newVal === "none" || newVal === "short" || newVal === "long") {
|
|
return newVal;
|
|
}
|
|
|
|
// Fall back to legacy cacheControlTtl with mapping
|
|
const legacy = extraParams?.cacheControlTtl;
|
|
if (legacy === "5m") {
|
|
return "short";
|
|
}
|
|
if (legacy === "1h") {
|
|
return "long";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function createStreamFnWithExtraParams(
|
|
baseStreamFn: StreamFn | undefined,
|
|
extraParams: Record<string, unknown> | undefined,
|
|
provider: string,
|
|
): StreamFn | undefined {
|
|
if (!extraParams || Object.keys(extraParams).length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const streamParams: CacheRetentionStreamOptions = {};
|
|
if (typeof extraParams.temperature === "number") {
|
|
streamParams.temperature = extraParams.temperature;
|
|
}
|
|
if (typeof extraParams.maxTokens === "number") {
|
|
streamParams.maxTokens = extraParams.maxTokens;
|
|
}
|
|
const cacheRetention = resolveCacheRetention(extraParams, provider);
|
|
if (cacheRetention) {
|
|
streamParams.cacheRetention = cacheRetention;
|
|
}
|
|
|
|
if (Object.keys(streamParams).length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
log.debug(`creating streamFn wrapper with params: ${JSON.stringify(streamParams)}`);
|
|
|
|
const underlying = baseStreamFn ?? streamSimple;
|
|
const wrappedStreamFn: StreamFn = (model, context, options) =>
|
|
underlying(model, context, {
|
|
...streamParams,
|
|
...options,
|
|
});
|
|
|
|
return wrappedStreamFn;
|
|
}
|
|
|
|
function isDirectOpenAIBaseUrl(baseUrl: unknown): boolean {
|
|
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const host = new URL(baseUrl).hostname.toLowerCase();
|
|
return host === "api.openai.com" || host === "chatgpt.com";
|
|
} catch {
|
|
const normalized = baseUrl.toLowerCase();
|
|
return normalized.includes("api.openai.com") || normalized.includes("chatgpt.com");
|
|
}
|
|
}
|
|
|
|
function shouldForceResponsesStore(model: {
|
|
api?: unknown;
|
|
provider?: unknown;
|
|
baseUrl?: unknown;
|
|
}): boolean {
|
|
if (typeof model.api !== "string" || typeof model.provider !== "string") {
|
|
return false;
|
|
}
|
|
if (!OPENAI_RESPONSES_APIS.has(model.api)) {
|
|
return false;
|
|
}
|
|
if (!OPENAI_RESPONSES_PROVIDERS.has(model.provider)) {
|
|
return false;
|
|
}
|
|
return isDirectOpenAIBaseUrl(model.baseUrl);
|
|
}
|
|
|
|
function createOpenAIResponsesStoreWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
|
|
const underlying = baseStreamFn ?? streamSimple;
|
|
return (model, context, options) => {
|
|
if (!shouldForceResponsesStore(model)) {
|
|
return underlying(model, context, options);
|
|
}
|
|
|
|
const originalOnPayload = options?.onPayload;
|
|
return underlying(model, context, {
|
|
...options,
|
|
onPayload: (payload) => {
|
|
if (payload && typeof payload === "object") {
|
|
(payload as { store?: unknown }).store = true;
|
|
}
|
|
originalOnPayload?.(payload);
|
|
},
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a streamFn wrapper that adds OpenRouter app attribution headers.
|
|
* These headers allow OpenClaw to appear on OpenRouter's leaderboard.
|
|
*/
|
|
function createOpenRouterHeadersWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
|
|
const underlying = baseStreamFn ?? streamSimple;
|
|
return (model, context, options) =>
|
|
underlying(model, context, {
|
|
...options,
|
|
headers: {
|
|
...OPENROUTER_APP_HEADERS,
|
|
...options?.headers,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply extra params (like temperature) to an agent's streamFn.
|
|
* Also adds OpenRouter app attribution headers when using the OpenRouter provider.
|
|
*
|
|
* @internal Exported for testing
|
|
*/
|
|
export function applyExtraParamsToAgent(
|
|
agent: { streamFn?: StreamFn },
|
|
cfg: OpenClawConfig | undefined,
|
|
provider: string,
|
|
modelId: string,
|
|
extraParamsOverride?: Record<string, unknown>,
|
|
): void {
|
|
const extraParams = resolveExtraParams({
|
|
cfg,
|
|
provider,
|
|
modelId,
|
|
});
|
|
const override =
|
|
extraParamsOverride && Object.keys(extraParamsOverride).length > 0
|
|
? Object.fromEntries(
|
|
Object.entries(extraParamsOverride).filter(([, value]) => value !== undefined),
|
|
)
|
|
: undefined;
|
|
const merged = Object.assign({}, extraParams, override);
|
|
const wrappedStreamFn = createStreamFnWithExtraParams(agent.streamFn, merged, provider);
|
|
|
|
if (wrappedStreamFn) {
|
|
log.debug(`applying extraParams to agent streamFn for ${provider}/${modelId}`);
|
|
agent.streamFn = wrappedStreamFn;
|
|
}
|
|
|
|
if (provider === "openrouter") {
|
|
log.debug(`applying OpenRouter app attribution headers for ${provider}/${modelId}`);
|
|
agent.streamFn = createOpenRouterHeadersWrapper(agent.streamFn);
|
|
}
|
|
|
|
// Work around upstream pi-ai hardcoding `store: false` for Responses API.
|
|
// Force `store=true` for direct OpenAI/OpenAI Codex providers so multi-turn
|
|
// server-side conversation state is preserved.
|
|
agent.streamFn = createOpenAIResponsesStoreWrapper(agent.streamFn);
|
|
}
|