Files
TREK/client/src/components/Settings/ai/aiConnectModel.ts
T
Maurice b8990782cc fix(ai): connect and read bookings through any OpenAI-compatible own server
Connecting an own server asked Ollama's /api/tags and gave up on any answer
that was not a 404, and every failure, a refused key included, read as "not
responding". The model list is now looked for at /api/tags, at the address as
entered and at /v1/models, an answer only counts when it is a list, and a
refused key or a server that answered without a list say so (#2808).

AI parsing through an own server always called Ollama's /api/chat, which an
OpenAI-compatible server such as Groq answers with 404. A server without it
now gets the same extraction over /chat/completions at the address as
entered (#2806).
2026-10-26 10:20:46 +01:00

177 lines
7.3 KiB
TypeScript

import type { AiConnectionStatus, AiConnectionView } from '@trek/shared';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
/**
* Pure helpers behind connecting an AI provider, shared by the settings card,
* the admin's instance connection and both phone twins. No React here.
*/
/** How a connection is being set up: a ChatGPT sign-in, a pasted key, or (admin only) an own server. */
export type AiConnectMode = 'chatgpt' | 'key' | 'custom';
/** The `code` of a refused AI request (`{ error, code }`), or null when the failure carries none. */
export function refusalCode(err: unknown): string | null {
if (err instanceof OnlineOnlyError) return 'offline';
const code = (err as { response?: { data?: { code?: unknown } } } | null)?.response?.data?.code;
return typeof code === 'string' ? code : null;
}
/**
* What a refused connect says, per code. Everything the server can answer while
* a key is tested or a sign-in finishes; anything else reads as the generic line.
*/
const CONNECT_ERRORS: Record<string, { messageKey: string }> = {
invalid_key: { messageKey: 'assistant.connect.badKey' },
unknown_model: { messageKey: 'assistant.connect.unknownModel' },
rate_limited: { messageKey: 'assistant.error.rateLimited' },
provider_unavailable: { messageKey: 'assistant.error.providerDown' },
not_eligible: { messageKey: 'assistant.error.notEligible' },
usage_limit: { messageKey: 'assistant.error.usageLimit' },
expired: { messageKey: 'assistant.connect.chatgptExpired' },
wrong_url: { messageKey: 'assistant.connect.chatgptWrong' },
account_mismatch: { messageKey: 'assistant.connect.chatgptOtherAccount' },
offline: { messageKey: 'assistant.error.offline' },
};
/** What a refused connect says for the `reason` it carries next to its code, ahead of the code's line. */
const CONNECT_REASONS: Record<string, { messageKey: string }> = {
no_model_list: { messageKey: 'assistant.connect.noModelList' },
};
/**
* The line a failed connect shows. Takes `{provider}` where the line names one.
* An own server that answered without a model list is up, so it gets its own
* line about the address instead of "not responding" (#2808).
*/
export function connectErrorKey(err: unknown): string {
const reason = (err as { response?: { data?: { reason?: unknown } } } | null)?.response?.data?.reason;
const byReason = typeof reason === 'string' ? CONNECT_REASONS[reason] : undefined;
return byReason?.messageKey ?? CONNECT_ERRORS[refusalCode(err) ?? '']?.messageKey ?? 'assistant.error.generic';
}
/** How each connection state reads on its pill. */
export const CONNECTION_STATUS: Record<
AiConnectionStatus,
{ labelKey: string; tone: 'success' | 'warning' | 'danger' | 'neutral' }
> = {
ok: { labelKey: 'assistant.connect.statusOk', tone: 'success' },
needs_reauth: { labelKey: 'assistant.connect.statusNeedsReauth', tone: 'warning' },
signed_out: { labelKey: 'assistant.connect.statusSignedOut', tone: 'neutral' },
error: { labelKey: 'assistant.connect.statusError', tone: 'danger' },
};
/** The way back into a connection that no longer works: a ChatGPT sign-in again, or a new key. */
export function repairModeOf(view: AiConnectionView): AiConnectMode | null {
if (view.status === 'ok') return null;
if (view.kind === 'chatgpt') return 'chatgpt';
return view.kind === 'custom' ? 'custom' : 'key';
}
/**
* Whether a pasted ChatGPT callback ended this sign-in rather than an earlier
* one: its `state` is the one the sign-in address carried. The server takes a
* sign-in only once, so an address from an earlier attempt still on the
* clipboard would use up the one in progress. Without a state on either side
* the server decides.
*/
export function callbackMatchesFlow(callback: string, authorizeUrl: string): boolean {
const stateOf = (address: string) => {
try {
return new URL(address.trim()).searchParams.get('state');
} catch {
return null;
}
};
const expected = stateOf(authorizeUrl);
const pasted = stateOf(callback);
return !expected || !pasted || pasted === expected;
}
// The OpenRouter sign-in leaves the page and comes back with a code only; the
// flow it belongs to waits here for that one return. Tab-scoped on purpose.
const OPENROUTER_FLOW_KEY = 'trek.ai.openrouterFlow';
export function rememberOpenrouterFlow(flowId: string): void {
try {
sessionStorage.setItem(OPENROUTER_FLOW_KEY, flowId);
} catch {
// Storage refused (private mode): the return then reads as an expired sign-in.
}
}
/** The flow the OpenRouter sign-in started, read once. */
export function takeOpenrouterFlow(): string | null {
try {
const flowId = sessionStorage.getItem(OPENROUTER_FLOW_KEY);
sessionStorage.removeItem(OPENROUTER_FLOW_KEY);
return flowId;
} catch {
return null;
}
}
/**
* The code an OpenRouter sign-in came back with, or `undefined` when the page
* was not opened by one. The return address already carries a query
* (`?tab=…&ai=openrouter`), and OpenRouter may add its code with a second `?`
* rather than an `&`, which leaves it inside the `ai` value
* (`ai=openrouter?code=…`); both shapes count.
*/
export function openrouterReturnCode(params: URLSearchParams): string | null | undefined {
const ai = params.get('ai');
if (ai === null) return undefined;
const [marker, rest] = ai.split('?', 2);
if (marker !== 'openrouter') return undefined;
return params.get('code') ?? new URLSearchParams(rest ?? '').get('code');
}
const PRIVATE_V4 = [/^10\./, /^127\./, /^169\.254\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./, /^0\./];
/**
* Whether Claude can reach this address from the internet: https on a public
* name. A LAN address, a loopback, a single-label host or `.local` cannot be
* reached from claude.ai, so the connector would fail after the user did
* everything right.
*/
export function isPublicHttpsUrl(raw: string): boolean {
let url: URL;
try {
url = new URL(raw);
} catch {
return false;
}
if (url.protocol !== 'https:') return false;
const host = url.hostname.toLowerCase();
if (host.startsWith('[')) return false;
if (!host.includes('.') || host === 'localhost' || host.endsWith('.local') || host.endsWith('.localhost')) {
return false;
}
return !PRIVATE_V4.some((range) => range.test(host));
}
/** The host of an http(s) address as typed, or '' while it is not one yet. */
export function httpHostOf(raw: string): string {
const value = raw.trim();
if (!/^https?:\/\//i.test(value)) return '';
try {
return new URL(value).host;
} catch {
return '';
}
}
/** An SVG as an `<img>` source, so its markup never lands in the page's DOM. */
export function svgDataUrl(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
/**
* What a user's AI connection serves, as its section's title and line: the
* assistant, AI parsing (booking import reads with the same connection), or both.
*/
export function aiSectionText(assistant: boolean, parsing: boolean): { titleKey: string; hintKey: string } {
if (!assistant) return { titleKey: 'settings.aiParsing.title', hintKey: 'assistant.connect.descriptionParsingOnly' };
if (parsing) return { titleKey: 'assistant.connect.title', hintKey: 'assistant.connect.descriptionWithParsing' };
return { titleKey: 'assistant.connect.title', hintKey: 'assistant.connect.description' };
}