openpetswithchatandmcp/apps/desktop/src/openapi-chat.ts
OpenPets Dev 6ab3bb64d8 feat(rebrand): rename OpenPets to FamiliarOS and pets to familiars
- Rename all user-facing and technical identifiers from OpenPets/Pet to FamiliarOS/Familiar.
- Rename packages from @open-pets/* to @familiaros/*; rename install-pet/pet-format packages.
- Rename plugin IDs and directories from openpets.* to familiaros.*.
- Rename IPC namespace from openpets:* to familiaros:* and state filenames from openpets-* to familiaros-* with legacy migration.
- Rename source files (pet-window, built-in-pet, default-pet-controller, etc.) to familiar equivalents.
- Update locales (en, es-419, ja, ko, pt-BR, zh-Hans, zh-Hant) and tray/pet context menu strings.
- Add Familiar naming feature: preference, settings input, tray menu display.
- Update assets and packaging config; all desktop tests pass.
2026-06-17 01:42:08 +00:00

1173 lines
43 KiB
TypeScript

import { app, safeStorage } from "electron";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { defaultOpenApiChatEndpoint, getAppStateSnapshot, normalizeOpenApiChatEndpoint } from "./app-state.js";
import { applyExternalPetReaction, applyInternalPetMessage, showDefaultPet } from "./default-familiar-controller.js";
import { error, info, warn } from "./logger.js";
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
import { buildRelevantKnowledgeContext } from "./knowledge-store.js";
import { buildRelevantMemoryContext, capturePromptMemories } from "./familiaros-memory.js";
import { getMcpChatClientManager, type McpChatToolDefinition } from "./mcp-chat-client.js";
import { speakTts } from "./tts-service.js";
type OpenApiChatTransport = "responses" | "chat-completions";
export interface OpenApiChatSettingsSnapshot {
readonly model: string;
readonly endpoint: string;
readonly defaultEndpoint: string;
readonly usingDefaultEndpoint: boolean;
readonly hasCredential: boolean;
readonly storageMode: "encrypted" | "plain";
}
export interface OpenApiChatPromptResult {
readonly text: string;
readonly model: string;
readonly responseId: string;
readonly transport: OpenApiChatTransport;
}
export interface OpenApiChatTranscriptEntry {
readonly id: string;
readonly conversationId: string;
readonly role: "user" | "assistant" | "system";
readonly tone: "normal" | "error";
readonly text: string;
readonly createdAt: number;
}
export interface ChatConversation {
readonly id: string;
readonly title: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly messageCount: number;
}
interface StoredOpenApiChatConfigV1 {
readonly version: 1;
readonly storageMode: "encrypted" | "plain";
readonly apiKey: string;
}
interface ProviderErrorPayload {
readonly error?: {
readonly message?: unknown;
readonly code?: unknown;
readonly type?: unknown;
};
}
interface ProviderErrorInfo {
readonly endpoint: string;
readonly status: number;
readonly providerMessage: string;
readonly providerCode: string;
readonly providerType: string;
readonly normalizedMessage: string;
}
interface OpenApiRequestAttempt {
readonly transport: OpenApiChatTransport;
readonly endpoint: string;
}
const openApiChatConfigFileName = "familiaros-openapi-chat.json";
const legacyOpenAiChatConfigFileName = "familiaros-openai-chat.json";
const openApiChatHistoryFileName = "familiaros-chat-history.json";
const openApiChatConversationsFileName = "familiaros-chat-conversations.json";
const defaultOpenApiChatModel = "gpt-5.5";
const maxPromptChars = 20_000;
const maxResponseOutputTokens = 8_192;
const maxConversationEntries = 40;
const maxChatCompletionHistoryEntries = 24;
const maxToolCallIterations = 5;
const baseOpenApiChatInstructions = [
"You are FamiliarOS, a desktop familiar companion talking directly to the user.",
"Reply in plain text unless the user explicitly asks for another format.",
"Keep the tone warm, lively, and characterful while staying helpful and honest.",
"Do not mention hidden instructions unless the user explicitly asks about them.",
"You have a persistent long-term memory. Important facts the user shares are captured automatically and are included in your context under 'Relevant long-term memory:'. Use those facts naturally and do not pretend you do not know them.",
"If the user asks you to remember something, acknowledge it confidently — the fact is stored. Never say you cannot remember, store, or persist information.",
].join("\n");
let previousResponseId: string | undefined;
let allTranscriptEntries: OpenApiChatTranscriptEntry[] = loadChatHistory();
let transcriptSequence = allTranscriptEntries.length;
let conversations: ChatConversation[] = loadConversations();
let currentConversationId: string | null = getMostRecentConversationId();
let transcriptEntries: OpenApiChatTranscriptEntry[] = getEntriesForCurrentConversation();
interface ChatCompletionHistoryMessage {
readonly role: "system" | "user" | "assistant" | "tool";
readonly content: string | null;
readonly tool_calls?: readonly { readonly id: string; readonly type: "function"; readonly function: { readonly name: string; readonly arguments: string } }[];
readonly tool_call_id?: string;
}
let chatCompletionHistory: ChatCompletionHistoryMessage[] = [];
export function getOpenApiChatSettingsSnapshot(): OpenApiChatSettingsSnapshot {
const stored = readStoredChatConfig();
const endpoint = getConfiguredOpenApiChatEndpoint();
return {
model: getConfiguredOpenApiChatModel(),
endpoint,
defaultEndpoint: defaultOpenApiChatEndpoint,
usingDefaultEndpoint: endpoint === defaultOpenApiChatEndpoint,
hasCredential: Boolean(getStoredCredential(stored)),
storageMode: stored?.storageMode ?? getPreferredStorageMode(),
};
}
function getEntriesForCurrentConversation(): OpenApiChatTranscriptEntry[] {
if (!currentConversationId) return [];
return allTranscriptEntries.filter((e) => e.conversationId === currentConversationId);
}
export function getOpenApiChatTranscriptEntries(): readonly OpenApiChatTranscriptEntry[] {
return getEntriesForCurrentConversation().slice();
}
export function getOpenApiChatSystemPrompt(): string {
return getAppStateSnapshot().preferences.openApiChatSystemPrompt?.trim() ?? "";
}
export function saveOpenApiCredential(credential: string): OpenApiChatSettingsSnapshot {
const trimmed = credential.trim();
if (!trimmed) {
throw new Error("API key or token cannot be empty.");
}
if (trimmed.length > 1_024 || /[\0\r\n]/.test(trimmed)) {
throw new Error("API key or token is not valid.");
}
const storageMode = getPreferredStorageMode();
const serialized = storageMode === "encrypted"
? safeStorage.encryptString(trimmed).toString("base64")
: trimmed;
writeStoredChatConfig({
version: 1,
storageMode,
apiKey: serialized,
});
previousResponseId = undefined;
info("app", "openapi chat credential saved", { storageMode });
return getOpenApiChatSettingsSnapshot();
}
export function clearOpenApiCredential(): OpenApiChatSettingsSnapshot {
writeStoredChatConfig({
version: 1,
storageMode: getPreferredStorageMode(),
apiKey: "",
});
previousResponseId = undefined;
info("app", "openapi chat credential cleared");
return getOpenApiChatSettingsSnapshot();
}
export function resetOpenApiConversation(): void {
previousResponseId = undefined;
chatCompletionHistory = [];
const newConversation = createConversation("New conversation");
currentConversationId = newConversation.id;
transcriptEntries = [];
info("app", "openapi conversation reset", { conversationId: newConversation.id });
}
export function getAllChatTranscriptEntries(): readonly OpenApiChatTranscriptEntry[] {
return allTranscriptEntries.slice();
}
export function resetOpenApiConversationContext(): void {
previousResponseId = undefined;
}
export function listConversations(): readonly ChatConversation[] {
return conversations.slice().sort((a, b) => b.updatedAt - a.updatedAt);
}
export function getCurrentConversationId(): string | null {
return currentConversationId;
}
export function createConversation(title?: string): ChatConversation {
const conversation: ChatConversation = {
id: `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title: title?.trim() || "New conversation",
createdAt: Date.now(),
updatedAt: Date.now(),
messageCount: 0,
};
conversations = [conversation, ...conversations].slice(0, 100);
saveConversations();
return conversation;
}
export function switchConversation(conversationId: string): void {
if (!conversations.some((c) => c.id === conversationId)) {
throw new Error("Conversation not found.");
}
currentConversationId = conversationId;
transcriptEntries = getEntriesForCurrentConversation();
chatCompletionHistory = [];
previousResponseId = undefined;
info("app", "openapi conversation switched", { conversationId });
}
export function deleteConversation(conversationId: string): void {
const before = conversations.length;
conversations = conversations.filter((c) => c.id !== conversationId);
if (conversations.length === before) {
throw new Error("Conversation not found.");
}
// Remove messages for this conversation
allTranscriptEntries = allTranscriptEntries.filter((e) => e.conversationId !== conversationId);
saveChatHistory();
saveConversations();
if (currentConversationId === conversationId) {
currentConversationId = conversations[0]?.id ?? null;
transcriptEntries = getEntriesForCurrentConversation();
chatCompletionHistory = [];
previousResponseId = undefined;
}
info("app", "openapi conversation deleted", { conversationId });
}
function updateConversationMessageCount(conversationId: string): void {
const count = allTranscriptEntries.filter((e) => e.conversationId === conversationId).length;
conversations = conversations.map((c) =>
c.id === conversationId ? { ...c, messageCount: count, updatedAt: Date.now() } : c
);
saveConversations();
}
function getMostRecentConversationId(): string | null {
const sorted = conversations.slice().sort((a, b) => b.updatedAt - a.updatedAt);
return sorted[0]?.id ?? null;
}
function autoTitleConversation(conversationId: string, text: string): void {
const conversation = conversations.find((c) => c.id === conversationId);
if (!conversation || conversation.title !== "New conversation") return;
const trimmed = text.trim();
if (!trimmed) return;
const title = trimmed.length > 36 ? `${trimmed.slice(0, 36).trim()}` : trimmed;
conversations = conversations.map((c) => (c.id === conversationId ? { ...c, title } : c));
saveConversations();
}
function getVanillaChatMcpToolsEnabled(): readonly string[] {
return getAppStateSnapshot().preferences.vanillaChatMcpTools ?? [];
}
function isVanillaChatMcpEnabled(): boolean {
return getVanillaChatMcpToolsEnabled().length > 0;
}
export async function sendOpenApiChatPrompt(prompt: string): Promise<OpenApiChatPromptResult> {
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt) {
throw new Error("Type a prompt before sending.");
}
if (trimmedPrompt.length > maxPromptChars) {
throw new Error(`Prompt is too long. Keep it under ${maxPromptChars} characters.`);
}
try {
const captured = capturePromptMemories(trimmedPrompt);
if (captured.length > 0) {
info("app", "memory captured", { component: "openapi-chat", count: captured.length, kinds: captured.map((e) => e.kind).join(",") });
}
} catch (err) {
error("app", "memory capture failed", { component: "openapi-chat", error: err instanceof Error ? err.message : String(err) });
}
appendTranscriptEntry("user", trimmedPrompt);
if (isVanillaChatMcpEnabled()) {
return sendOpenApiChatPromptWithTools(trimmedPrompt);
}
return sendOpenApiChatPromptPlain(trimmedPrompt);
}
function speakAssistantResponse(text: string): void {
void speakTts(text, "prompt").catch((error: unknown) => {
warn("app", "TTS for assistant response failed", { error: error instanceof Error ? error.message : String(error) });
});
}
async function sendOpenApiChatPromptPlain(prompt: string): Promise<OpenApiChatPromptResult> {
const credential = getRequiredCredential();
const model = getConfiguredOpenApiChatModel();
const endpoint = getConfiguredOpenApiChatEndpoint();
const previousId = previousResponseId;
showOpenApiPendingState();
info("app", "openapi prompt submitted", {
model,
endpointHost: getEndpointHost(endpoint),
usingDefaultEndpoint: endpoint === defaultOpenApiChatEndpoint,
promptLength: prompt.length,
hasPreviousResponse: Boolean(previousId),
});
const attempts = buildRequestAttempts(endpoint);
let lastProviderError: ProviderErrorInfo | undefined;
let lastResponseTextError: string | undefined;
for (let index = 0; index < attempts.length; index += 1) {
const attempt = attempts[index];
let response: Response;
try {
response = await fetch(attempt.endpoint, {
method: "POST",
headers: buildOpenApiRequestHeaders(attempt.endpoint, credential),
body: JSON.stringify(buildRequestBody(attempt.transport, {
model,
prompt,
previousResponseId: attempt.transport === "responses" ? previousId : undefined,
})),
});
} catch (error) {
const message = error instanceof Error && error.message
? error.message
: "The OpenAPI chat request failed before a response was received.";
handleOpenApiFailure(message, {
model,
endpointHost: getEndpointHost(attempt.endpoint),
transport: attempt.transport,
hasPreviousResponse: Boolean(previousId),
failureKind: "network",
});
throw new Error(message);
}
if (!response.ok) {
const providerError = await readProviderError(response, attempt.endpoint);
if (shouldTryChatCompletionsFallback(attempt, providerError) && hasNextAttempt(attempts, index)) {
lastProviderError = providerError;
continue;
}
handleOpenApiFailure(providerError.normalizedMessage, {
model,
endpointHost: getEndpointHost(attempt.endpoint),
transport: attempt.transport,
status: response.status,
hasPreviousResponse: Boolean(previousId),
failureKind: "http",
});
throw new Error(providerError.normalizedMessage);
}
const payload = await response.json() as Record<string, unknown>;
const parsed = attempt.transport === "responses"
? parseResponsesPayload(payload)
: parseChatCompletionsPayload(payload);
if (!parsed.text) {
if (attempt.transport === "responses" && payloadHasChatCompletionsShape(payload) && hasNextAttempt(attempts, index)) {
lastResponseTextError = "The provider returned a chat-completions style payload from the responses route.";
continue;
}
const message = attempt.transport === "responses"
? "The provider returned an empty chat response."
: "The provider returned an empty chat-completions response.";
handleOpenApiFailure(message, {
model,
endpointHost: getEndpointHost(attempt.endpoint),
transport: attempt.transport,
hasPreviousResponse: Boolean(previousId),
failureKind: "empty",
});
throw new Error(message);
}
const responseId = parsed.responseId ?? `response-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
previousResponseId = attempt.transport === "responses" ? parsed.responseId : undefined;
appendTranscriptEntry("assistant", parsed.text);
speakAssistantResponse(parsed.text);
showOpenApiSuccessState(parsed.text);
info("app", "openapi prompt completed", {
model,
endpointHost: getEndpointHost(attempt.endpoint),
transport: attempt.transport,
textLength: parsed.text.length,
responseId,
carriedResponseId: Boolean(previousResponseId),
hadFallbackProviderError: Boolean(lastProviderError),
hadFallbackPayloadMismatch: Boolean(lastResponseTextError),
});
return {
text: parsed.text,
model,
responseId,
transport: attempt.transport,
};
}
const fallbackMessage = lastProviderError?.normalizedMessage
?? lastResponseTextError
?? "The provider could not produce a usable reply.";
handleOpenApiFailure(fallbackMessage, {
model,
endpointHost: getEndpointHost(endpoint),
hasPreviousResponse: Boolean(previousId),
failureKind: "exhausted-attempts",
});
throw new Error(fallbackMessage);
}
function getConfiguredOpenApiChatModel(): string {
return normalizeChatModel(getAppStateSnapshot().preferences.openApiChatModel) ?? defaultOpenApiChatModel;
}
function getConfiguredOpenApiChatEndpoint(): string {
return normalizeOpenApiChatEndpoint(getAppStateSnapshot().preferences.openApiChatEndpoint) ?? defaultOpenApiChatEndpoint;
}
function buildOpenApiInstructions(prompt: string): string {
const parts: string[] = [];
if (getAppStateSnapshot().preferences.openApiChatBaseInstructionsEnabled !== false) {
parts.push(baseOpenApiChatInstructions);
}
const customCharacterPrompt = getOpenApiChatSystemPrompt();
if (customCharacterPrompt) {
parts.push(`User-defined familiar character instructions:\n${customCharacterPrompt}`);
}
const chatHistoryForMemory = allTranscriptEntries
.filter((e) => e.conversationId !== currentConversationId && e.role !== "system")
.slice(-60)
.map((e) => ({ text: e.text, role: e.role, createdAt: e.createdAt }));
const memoryContext = buildRelevantMemoryContext(prompt, chatHistoryForMemory);
if (memoryContext) {
parts.push(memoryContext);
}
const knowledgeContext = buildRelevantKnowledgeContext(prompt);
if (knowledgeContext) {
parts.push(knowledgeContext);
}
return parts.join("\n\n");
}
export function normalizeChatModel(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined;
return trimmed;
}
function buildRequestAttempts(endpoint: string): readonly OpenApiRequestAttempt[] {
const url = safeParseUrl(endpoint);
if (!url) {
return [{ transport: "responses", endpoint }];
}
const normalizedPath = url.pathname.replace(/\/+$/, "");
if (normalizedPath.endsWith("/chat/completions")) {
return [{ transport: "chat-completions", endpoint: url.toString() }];
}
if (normalizedPath.endsWith("/responses")) {
if (isOfficialOpenAiEndpoint(endpoint) || isAzureOpenAiEndpoint(endpoint)) {
return [{ transport: "responses", endpoint: url.toString() }];
}
return dedupeAttempts([
{ transport: "responses", endpoint: url.toString() },
{ transport: "chat-completions", endpoint: withPathname(url, replaceTransportPath(normalizedPath, "chat-completions")) },
]);
}
if (normalizedPath.endsWith("/v1")) {
return dedupeAttempts([
{ transport: "responses", endpoint: withPathname(url, `${normalizedPath}/responses`) },
{ transport: "chat-completions", endpoint: withPathname(url, `${normalizedPath}/chat/completions`) },
]);
}
return [{ transport: "responses", endpoint: url.toString() }];
}
function dedupeAttempts(attempts: readonly OpenApiRequestAttempt[]): readonly OpenApiRequestAttempt[] {
const seen = new Set<string>();
const deduped: OpenApiRequestAttempt[] = [];
for (const attempt of attempts) {
const key = `${attempt.transport}:${attempt.endpoint}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(attempt);
}
return deduped;
}
function withPathname(url: URL, pathname: string): string {
const clone = new URL(url.toString());
clone.pathname = pathname;
return clone.toString();
}
function replaceTransportPath(pathname: string, transport: OpenApiChatTransport): string {
const suffix = transport === "responses" ? "/responses" : "/chat/completions";
return pathname.replace(/\/(?:responses|chat\/completions)$/, suffix);
}
function buildRequestBody(transport: OpenApiChatTransport, input: {
readonly model: string;
readonly prompt: string;
readonly previousResponseId?: string;
}): Record<string, unknown> {
if (transport === "responses") {
return {
model: input.model,
instructions: buildOpenApiInstructions(input.prompt),
previous_response_id: input.previousResponseId,
max_output_tokens: maxResponseOutputTokens,
input: [
{
role: "user",
content: [
{
type: "input_text",
text: input.prompt,
},
],
},
],
};
}
return {
model: input.model,
max_tokens: maxResponseOutputTokens,
messages: buildChatCompletionsMessages(input.prompt),
};
}
function buildChatCompletionsMessages(prompt: string): readonly { readonly role: "system" | "user" | "assistant"; readonly content: string }[] {
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [
{
role: "system",
content: buildOpenApiInstructions(prompt),
},
];
const history = transcriptEntries
.filter((entry) => entry.role === "user" || entry.role === "assistant")
.slice(-maxChatCompletionHistoryEntries);
for (const entry of history) {
messages.push({
role: entry.role,
content: entry.text,
});
}
return messages;
}
function parseResponsesPayload(payload: Record<string, unknown>): { readonly text: string; readonly responseId?: string } {
const text = extractResponsesOutputText(payload).trim();
const responseId = typeof payload.id === "string" && payload.id ? payload.id : undefined;
return { text, responseId };
}
function parseChatCompletionsPayload(payload: Record<string, unknown>): { readonly text: string; readonly responseId: string } {
const text = extractChatCompletionsText(payload).trim();
const responseId = typeof payload.id === "string" && payload.id
? payload.id
: `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return { text, responseId };
}
function buildOpenApiRequestHeaders(endpoint: string, credential: string): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${credential}`,
};
if (isAzureOpenAiEndpoint(endpoint)) {
headers["api-key"] = credential;
} else if (!isOfficialOpenAiEndpoint(endpoint)) {
headers["x-api-key"] = credential;
}
return headers;
}
function isAzureOpenAiEndpoint(endpoint: string): boolean {
const url = safeParseUrl(endpoint);
return Boolean(url && url.hostname.endsWith(".openai.azure.com"));
}
function isOfficialOpenAiEndpoint(endpoint: string): boolean {
const url = safeParseUrl(endpoint);
return Boolean(url && url.hostname === "api.openai.com");
}
function safeParseUrl(endpoint: string): URL | undefined {
try {
return new URL(endpoint);
} catch {
return undefined;
}
}
function getEndpointHost(endpoint: string): string {
return safeParseUrl(endpoint)?.host ?? "unknown";
}
function getRequiredCredential(): string {
const credential = getStoredCredential(readStoredChatConfig());
if (!credential) {
throw new Error("Add your API key or token in Settings before sending a prompt.");
}
return credential;
}
function getStoredCredential(stored: StoredOpenApiChatConfigV1 | null): string | undefined {
if (!stored?.apiKey) return undefined;
if (stored.storageMode === "encrypted") {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error("Encrypted chat credentials are unavailable on this machine right now. Re-save the key in Settings.");
}
return safeStorage.decryptString(Buffer.from(stored.apiKey, "base64"));
}
return stored.apiKey;
}
function getPreferredStorageMode(): "encrypted" | "plain" {
return safeStorage.isEncryptionAvailable() ? "encrypted" : "plain";
}
function getChatConfigPath(fileName = openApiChatConfigFileName): string {
return join(app.getPath("userData"), fileName);
}
function readStoredChatConfig(): StoredOpenApiChatConfigV1 | null {
for (const path of [getChatConfigPath(), getChatConfigPath(legacyOpenAiChatConfigFileName)]) {
try {
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
if (!isRecord(raw) || raw.version !== 1) continue;
const storageMode = raw.storageMode === "encrypted" || raw.storageMode === "plain" ? raw.storageMode : "plain";
const apiKey = typeof raw.apiKey === "string" ? raw.apiKey : "";
return {
version: 1,
storageMode,
apiKey,
};
} catch {
// Keep checking fallbacks.
}
}
return null;
}
function writeStoredChatConfig(config: StoredOpenApiChatConfigV1): void {
const path = getChatConfigPath();
mkdirSync(dirname(path), { recursive: true });
const tempPath = `${path}.${process.pid}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
renameSync(tempPath, path);
const legacyPath = getChatConfigPath(legacyOpenAiChatConfigFileName);
if (existsSync(legacyPath)) {
rmSync(legacyPath, { force: true });
}
}
async function readProviderError(response: Response, endpoint: string): Promise<ProviderErrorInfo> {
let providerMessage = "";
let providerCode = "";
let providerType = "";
try {
const payload = await response.json() as ProviderErrorPayload;
providerMessage = typeof payload?.error?.message === "string" ? payload.error.message : "";
providerCode = typeof payload?.error?.code === "string" ? payload.error.code : "";
providerType = typeof payload?.error?.type === "string" ? payload.error.type : "";
} catch {
// Fall through to a generic message.
}
return {
endpoint,
status: response.status,
providerMessage,
providerCode,
providerType,
normalizedMessage: normalizeProviderErrorMessage({
endpoint,
status: response.status,
providerMessage,
providerCode,
providerType,
}),
};
}
function shouldTryChatCompletionsFallback(attempt: OpenApiRequestAttempt, errorInfo: ProviderErrorInfo): boolean {
if (attempt.transport !== "responses") return false;
if (isOfficialOpenAiEndpoint(attempt.endpoint) || isAzureOpenAiEndpoint(attempt.endpoint)) return false;
const normalizedProviderMessage = sanitizeProviderMessage(errorInfo.providerMessage);
const haystack = `${errorInfo.providerCode} ${errorInfo.providerType} ${normalizedProviderMessage}`.toLowerCase();
if (errorInfo.status === 401 || errorInfo.status === 403 || errorInfo.status === 429) return false;
if (haystack.includes("authentication header") || haystack.includes("invalid api key") || haystack.includes("incorrect api key")) return false;
if (haystack.includes("quota") || haystack.includes("billing") || haystack.includes("credit") || haystack.includes("balance")) return false;
if (errorInfo.status === 404 || errorInfo.status === 405) return true;
if (errorInfo.status === 400 || errorInfo.status === 415 || errorInfo.status === 422 || errorInfo.status === 501) {
return [
"unknown parameter",
"unknown url",
"unsupported",
"unsupported route",
"not found",
"no route",
"responses",
"input",
"instructions",
"previous_response_id",
"max_output_tokens",
"invalid_request_error",
"chat/completions",
].some((needle) => haystack.includes(needle));
}
return false;
}
function hasNextAttempt(attempts: readonly OpenApiRequestAttempt[], index: number): boolean {
return index < attempts.length - 1;
}
function normalizeProviderErrorMessage(input: {
readonly endpoint: string;
readonly status: number;
readonly providerMessage: string;
readonly providerCode: string;
readonly providerType: string;
}): string {
const providerName = getEndpointProviderLabel(input.endpoint);
const normalizedProviderMessage = sanitizeProviderMessage(input.providerMessage);
const haystack = `${input.providerCode} ${input.providerType} ${normalizedProviderMessage}`.toLowerCase();
if (haystack.includes("missing authentication header") || haystack.includes("authentication header")) {
return `${providerName} says the request arrived without usable authentication. Re-save the credential in Settings after choosing the matching preset or endpoint.`;
}
if (input.status === 401 || haystack.includes("invalid api key") || haystack.includes("incorrect api key")) {
return `${providerName} rejected the saved credential. Re-save it in Settings and confirm the preset matches the endpoint.`;
}
if (isOfficialOpenAiEndpoint(input.endpoint) && (haystack.includes("insufficient_quota") || haystack.includes("current quota") || haystack.includes("billing"))) {
return "OpenAI is reporting an API billing or quota problem for this key. ChatGPT app subscriptions do not automatically cover API usage.";
}
if (haystack.includes("tokens exhausted") || haystack.includes("insufficient_quota") || haystack.includes("quota") || haystack.includes("billing") || haystack.includes("credit") || haystack.includes("balance")) {
return `${providerName} is reporting an account quota, credit, or billing problem for this credential, not a prompt-length issue.`;
}
if (input.status === 429 || haystack.includes("rate limit")) {
return `${providerName} rate-limited this request. Wait a moment and try again.`;
}
if (normalizedProviderMessage) {
return normalizedProviderMessage;
}
return `${providerName} request failed with HTTP ${input.status}.`;
}
function getEndpointProviderLabel(endpoint: string): string {
if (isAzureOpenAiEndpoint(endpoint)) return "Azure OpenAI";
const url = safeParseUrl(endpoint);
if (!url) return "OpenAPI provider";
if (url.hostname === "api.openai.com") return "OpenAI";
if (url.hostname === "openrouter.ai") return "OpenRouter";
if (url.hostname === "api.moonshot.cn") return "Moonshot";
if (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1") return "Local OpenAPI provider";
return "OpenAPI provider";
}
function sanitizeProviderMessage(message: string): string {
return message
.replace(/\s+/g, " ")
.replace(/\s*For more information on this error, read the docs:.*$/i, "")
.trim();
}
function extractResponsesOutputText(payload: Record<string, unknown>): string {
if (typeof payload.output_text === "string") return payload.output_text;
const output = Array.isArray(payload.output) ? payload.output : [];
const parts: string[] = [];
for (const item of output) {
if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
for (const contentPart of item.content) {
if (!isRecord(contentPart) || contentPart.type !== "output_text" || typeof contentPart.text !== "string") continue;
parts.push(contentPart.text);
}
}
return parts.join("");
}
function extractChatCompletionsText(payload: Record<string, unknown>): string {
const choices = Array.isArray(payload.choices) ? payload.choices : [];
const parts: string[] = [];
for (const choice of choices) {
if (!isRecord(choice) || !isRecord(choice.message)) continue;
const content = choice.message.content;
if (typeof content === "string") {
parts.push(content);
continue;
}
if (!Array.isArray(content)) continue;
for (const part of content) {
if (!isRecord(part)) continue;
if (typeof part.text === "string") {
parts.push(part.text);
} else if (isRecord(part.text) && typeof part.text.value === "string") {
parts.push(part.text.value);
}
}
}
return parts.join("");
}
function payloadHasChatCompletionsShape(payload: Record<string, unknown>): boolean {
return Array.isArray(payload.choices);
}
function showOpenApiPendingState(): void {
showDefaultPet();
applyExternalPetReaction("thinking");
}
function showOpenApiSuccessState(message: string): void {
applyOpenApiPetMessage(message, "success");
}
function showOpenApiFailureState(message: string): void {
applyOpenApiPetMessage(message, "error");
}
function handleOpenApiFailure(message: string, fields: Record<string, unknown>): void {
appendTranscriptEntry("system", message, "error");
showOpenApiFailureState(message);
warn("app", "openapi prompt failed", fields);
}
function applyOpenApiPetMessage(message: string, reaction: FamiliarOSReaction): void {
applyInternalPetMessage(message, {
reaction,
fullMessage: true,
sticky: true,
});
}
function appendTranscriptEntry(role: OpenApiChatTranscriptEntry["role"], text: string, tone: OpenApiChatTranscriptEntry["tone"] = "normal"): void {
if (!currentConversationId) {
const conv = createConversation();
currentConversationId = conv.id;
}
const conversationId = currentConversationId;
const entry: OpenApiChatTranscriptEntry = {
id: `chat-${Date.now()}-${++transcriptSequence}`,
conversationId,
role,
tone,
text,
createdAt: Date.now(),
};
// Add entry and cap per-conversation
const otherEntries = allTranscriptEntries.filter((e) => e.conversationId !== conversationId);
const convEntries = allTranscriptEntries.filter((e) => e.conversationId === conversationId);
allTranscriptEntries = [...otherEntries, ...convEntries.slice(-(maxConversationEntries - 1)), entry];
transcriptEntries = getEntriesForCurrentConversation();
saveChatHistory();
updateConversationMessageCount(conversationId);
if (role === "user") {
autoTitleConversation(conversationId, text);
}
}
async function sendOpenApiChatPromptWithTools(prompt: string): Promise<OpenApiChatPromptResult> {
const credential = getRequiredCredential();
const model = getConfiguredOpenApiChatModel();
const endpoint = getConfiguredOpenApiChatEndpoint();
showOpenApiPendingState();
info("app", "openapi prompt with tools submitted", {
model,
endpointHost: getEndpointHost(endpoint),
promptLength: prompt.length,
});
const manager = getMcpChatClientManager();
const enabledTools = getVanillaChatMcpToolsEnabled();
try {
await manager.startEnabledServers(enabledTools);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
warn("app", "Failed to start some MCP servers for vanilla chat", { message });
}
const mcpTools = manager.listTools();
if (mcpTools.length === 0) {
warn("app", "No MCP tools available; falling back to plain chat");
return sendOpenApiChatPromptPlain(prompt);
}
const chatEndpoint = forceChatCompletionsEndpoint(endpoint);
const systemContent = buildOpenApiInstructionsWithTools(prompt, mcpTools);
if (chatCompletionHistory.length === 0 || chatCompletionHistory[0].role !== "system") {
chatCompletionHistory = [{ role: "system", content: systemContent }, ...chatCompletionHistory];
} else {
chatCompletionHistory = [{ role: "system", content: systemContent }, ...chatCompletionHistory.slice(1)];
}
chatCompletionHistory.push({ role: "user", content: prompt });
chatCompletionHistory = chatCompletionHistory.slice(-maxChatCompletionHistoryEntries);
let finalText = "";
let responseId = "";
let iteration = 0;
while (iteration < maxToolCallIterations) {
iteration += 1;
const messages = chatCompletionHistory.map((m) => ({
role: m.role,
content: m.content,
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
}));
let response: Response;
try {
response = await fetch(chatEndpoint, {
method: "POST",
headers: buildOpenApiRequestHeaders(chatEndpoint, credential),
body: JSON.stringify({
model,
max_tokens: maxResponseOutputTokens,
messages,
tools: mcpTools,
tool_choice: "auto",
}),
});
} catch (error) {
const message = error instanceof Error && error.message
? error.message
: "The OpenAPI chat request failed before a response was received.";
handleOpenApiFailure(message, { model, endpointHost: getEndpointHost(chatEndpoint), failureKind: "network" });
throw new Error(message);
}
if (!response.ok) {
const providerError = await readProviderError(response, chatEndpoint);
handleOpenApiFailure(providerError.normalizedMessage, {
model,
endpointHost: getEndpointHost(chatEndpoint),
status: response.status,
failureKind: "http",
});
throw new Error(providerError.normalizedMessage);
}
const payload = await response.json() as Record<string, unknown>;
const choice = extractChatCompletionsChoice(payload);
if (!choice) {
const message = "The provider returned an empty chat-completions response.";
handleOpenApiFailure(message, { model, endpointHost: getEndpointHost(chatEndpoint), failureKind: "empty" });
throw new Error(message);
}
responseId = typeof payload.id === "string" && payload.id ? payload.id : `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
if (choice.message.tool_calls && choice.message.tool_calls.length > 0) {
chatCompletionHistory.push({
role: "assistant",
content: choice.message.content ?? null,
tool_calls: choice.message.tool_calls,
});
for (const toolCall of choice.message.tool_calls) {
let toolResultText: string;
try {
const args = JSON.parse(toolCall.function.arguments) as unknown;
toolResultText = await manager.callTool(toolCall.function.name, args);
} catch (err) {
toolResultText = `Error executing tool ${toolCall.function.name}: ${err instanceof Error ? err.message : String(err)}`;
}
chatCompletionHistory.push({
role: "tool",
tool_call_id: toolCall.id,
content: toolResultText,
});
}
continue;
}
finalText = choice.message.content ?? "";
chatCompletionHistory.push({
role: "assistant",
content: choice.message.content ?? null,
});
break;
}
if (!finalText) {
const message = "The provider could not produce a usable reply after tool execution.";
handleOpenApiFailure(message, { model, endpointHost: getEndpointHost(chatEndpoint), failureKind: "empty" });
throw new Error(message);
}
appendTranscriptEntry("assistant", finalText);
speakAssistantResponse(finalText);
showOpenApiSuccessState(finalText);
info("app", "openapi prompt with tools completed", {
model,
endpointHost: getEndpointHost(chatEndpoint),
textLength: finalText.length,
responseId,
toolIterations: iteration,
});
return {
text: finalText,
model,
responseId,
transport: "chat-completions",
};
}
function forceChatCompletionsEndpoint(endpoint: string): string {
if (endpoint.endsWith("/chat/completions")) return endpoint;
if (endpoint.endsWith("/responses")) return endpoint.replace(/\/responses$/, "/chat/completions");
if (endpoint.endsWith("/v1")) return `${endpoint}/chat/completions`;
return `${endpoint.replace(/\/+$/, "")}/chat/completions`;
}
function buildOpenApiInstructionsWithTools(prompt: string, tools: McpChatToolDefinition[]): string {
const toolNames = tools.map((t) => t.function.name).join(", ");
const base = buildOpenApiInstructions(prompt);
return `${base}\n\nYou have access to the following tools: ${toolNames}. When you need to use a tool, respond with a tool call. The system will execute it and return the result. Do not make up tool results.`;
}
interface ChatCompletionChoice {
readonly message: {
readonly content?: string | null;
readonly tool_calls?: readonly { readonly id: string; readonly type: "function"; readonly function: { readonly name: string; readonly arguments: string } }[];
};
}
function extractChatCompletionsChoice(payload: Record<string, unknown>): ChatCompletionChoice | undefined {
const choices = Array.isArray(payload.choices) ? payload.choices : [];
const first = choices[0];
if (!first || typeof first !== "object") return undefined;
const message = (first as Record<string, unknown>).message;
if (!message || typeof message !== "object") return undefined;
const msg = message as Record<string, unknown>;
const content = typeof msg.content === "string" ? msg.content : null;
const rawToolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
type ToolCallItem = NonNullable<ChatCompletionChoice["message"]["tool_calls"]> extends readonly (infer T)[] ? T : never;
const tool_calls = rawToolCalls.map((tc): ToolCallItem | null => {
if (!tc || typeof tc !== "object") return null;
const t = tc as Record<string, unknown>;
const id = typeof t.id === "string" ? t.id : "";
const type: "function" = t.type === "function" ? "function" : "function";
const fn = t.function;
if (!fn || typeof fn !== "object") return null;
const f = fn as Record<string, unknown>;
const name = typeof f.name === "string" ? f.name : "";
const args = typeof f.arguments === "string" ? f.arguments : "";
if (!id || !name) return null;
return { id, type, function: { name, arguments: args } };
}).filter((tc): tc is NonNullable<typeof tc> => tc !== null);
return { message: { content, ...(tool_calls.length > 0 ? { tool_calls } : {}) } };
}
function loadChatHistory(): OpenApiChatTranscriptEntry[] {
try {
const path = getChatConfigPath(openApiChatHistoryFileName);
if (!existsSync(path)) return [];
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
if (!Array.isArray(raw)) return [];
const hasConversationId = raw.some((entry) => isRecord(entry) && typeof entry.conversationId === "string");
if (!hasConversationId && raw.length > 0) {
// Migrate old format: wrap all entries in a default conversation
const defaultConv = createConversation("Previous chats");
return raw.filter((entry): entry is OpenApiChatTranscriptEntry =>
isRecord(entry) &&
typeof entry.id === "string" &&
(entry.role === "user" || entry.role === "assistant" || entry.role === "system") &&
typeof entry.text === "string" &&
(entry.tone === "normal" || entry.tone === "error") &&
typeof entry.createdAt === "number"
).map((entry) => ({ ...entry, conversationId: defaultConv.id }));
}
return raw.filter((entry): entry is OpenApiChatTranscriptEntry =>
isRecord(entry) &&
typeof entry.id === "string" &&
typeof entry.conversationId === "string" &&
(entry.role === "user" || entry.role === "assistant" || entry.role === "system") &&
typeof entry.text === "string" &&
(entry.tone === "normal" || entry.tone === "error") &&
typeof entry.createdAt === "number"
);
} catch {
return [];
}
}
function saveChatHistory(): void {
try {
const path = getChatConfigPath(openApiChatHistoryFileName);
const data = allTranscriptEntries.slice(-1000); // Cap global store
const tempPath = `${path}.${process.pid}.tmp`;
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf8");
renameSync(tempPath, path);
} catch {
// ignore
}
}
function loadConversations(): ChatConversation[] {
try {
const path = getChatConfigPath(openApiChatConversationsFileName);
if (!existsSync(path)) return [];
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
if (!isRecord(raw) || !Array.isArray(raw.conversations)) return [];
return raw.conversations.filter((c): c is ChatConversation =>
isRecord(c) &&
typeof c.id === "string" &&
typeof c.title === "string" &&
typeof c.createdAt === "number" &&
typeof c.updatedAt === "number" &&
typeof c.messageCount === "number"
);
} catch {
return [];
}
}
function saveConversations(): void {
try {
const path = getChatConfigPath(openApiChatConversationsFileName);
const data = { version: 1, conversations };
const tempPath = `${path}.${process.pid}.tmp`;
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf8");
renameSync(tempPath, path);
} catch {
// ignore
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}