feat: rebuild xiteng.site homelab platform
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
|
||||
|
||||
export function createBrowserProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) {
|
||||
const proxy = provider.connection.proxy;
|
||||
if (!proxy) return fetch;
|
||||
if (proxy.type !== "relay") throw new Error(`Unsupported frontend proxy: ${proxy.type}`);
|
||||
return async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const headers = new Headers(init.headers);
|
||||
const relayHeaders: Record<string, string> = {"Content-Type": "application/json"};
|
||||
if (secret.proxy?.token) relayHeaders.Authorization = `Bearer ${secret.proxy.token}`;
|
||||
return fetch(proxy.url, {
|
||||
method: "POST",
|
||||
headers: relayHeaders,
|
||||
body: JSON.stringify({
|
||||
url: String(input),
|
||||
method: init.method || "GET",
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
body: typeof init.body === "string" ? init.body : null
|
||||
}),
|
||||
signal: init.signal
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
|
||||
|
||||
function inferredDiscoveryUrl(provider: ProviderDefinition, baseUrl: string) {
|
||||
if (provider.discovery.type === "anthropic-models-list") return `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200`;
|
||||
if (provider.discovery.type === "google-models-list") return `${baseUrl}/models?pageSize=200`;
|
||||
return `${baseUrl}/models`;
|
||||
}
|
||||
|
||||
export function applyBrowserProviderSettings(provider: ProviderDefinition, secret: ProviderSecret) {
|
||||
const configuredBaseUrl = secret.provider?.baseUrl?.trim();
|
||||
if (!configuredBaseUrl) return provider;
|
||||
const url = new URL(configuredBaseUrl);
|
||||
if (!["http:", "https:"].includes(url.protocol)) throw new Error("Provider Base URL 必须使用 http 或 https");
|
||||
const baseUrl = configuredBaseUrl.replace(/\/+$/, "");
|
||||
const originalBaseUrl = provider.connection.baseUrl.replace(/\/+$/, "");
|
||||
const discoveryUrl = provider.discovery.url.startsWith(originalBaseUrl)
|
||||
? inferredDiscoveryUrl(provider, baseUrl)
|
||||
: provider.discovery.url;
|
||||
return {
|
||||
...provider,
|
||||
connection: {...provider.connection, baseUrl},
|
||||
discovery: {...provider.discovery, url: discoveryUrl}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type {Conversation, ConversationSummary, StoredChatMessage} from "./conversation-types";
|
||||
import type {GenerationSettings} from "./generation-settings";
|
||||
import {
|
||||
cacheConversation,
|
||||
cacheConversationSummaries,
|
||||
listPendingConversationChanges,
|
||||
loadCachedConversation,
|
||||
loadCachedConversationSummaries,
|
||||
queueConversationChange,
|
||||
removeCachedConversation,
|
||||
removePendingConversationChange
|
||||
} from "./offline-history";
|
||||
|
||||
class ConversationHttpError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function conversationRequest<T>(path: string, init?: RequestInit) {
|
||||
const response = await fetch(path, {
|
||||
cache: "no-store",
|
||||
...init,
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
...(init?.body ? {"Content-Type": "application/json"} : {}),
|
||||
...(init?.headers || {})
|
||||
}
|
||||
});
|
||||
if (response.status === 204) return undefined as T;
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new ConversationHttpError(payload.error || `HTTP ${response.status}`, response.status);
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function isNetworkFailure(error: unknown) {
|
||||
return error instanceof TypeError || (typeof navigator !== "undefined" && !navigator.onLine);
|
||||
}
|
||||
|
||||
export async function listConversationHistory() {
|
||||
try {
|
||||
const payload = await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations");
|
||||
await cacheConversationSummaries(payload.conversations);
|
||||
return payload.conversations;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversationSummaries();
|
||||
if (cached.length === 0) throw error;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createConversationHistory(providerId: string, model: string, generationSettings: GenerationSettings) {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>("/api/conversations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({providerId, model, generationSettings})
|
||||
});
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
}
|
||||
|
||||
export async function getConversationHistory(id: string) {
|
||||
try {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`);
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (!cached) throw error;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConversationHistory(id: string, providerId: string, model: string, generationSettings: GenerationSettings) {
|
||||
const body = JSON.stringify({providerId, model, generationSettings});
|
||||
try {
|
||||
return await conversationRequest<{updated: true}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PATCH", body});
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (cached) await cacheConversation({...cached, providerId, model, generationSettings, updatedAt: new Date().toISOString()});
|
||||
await queueConversationChange({conversationId: id, method: "PATCH", body});
|
||||
return {updated: true as const};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConversationHistory(id: string, providerId: string, model: string, messages: StoredChatMessage[]) {
|
||||
const body = JSON.stringify({providerId, model, messages});
|
||||
try {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PUT", body});
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (!cached) throw error;
|
||||
const firstUserText = messages.find((message) => message.role === "user")?.parts
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => String(part.text)).join(" ").replace(/\s+/g, " ").trim();
|
||||
const updated: Conversation = {
|
||||
...cached,
|
||||
title: firstUserText?.slice(0, 48) || cached.title,
|
||||
providerId,
|
||||
model,
|
||||
messages,
|
||||
messageCount: messages.length,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
await cacheConversation(updated);
|
||||
await queueConversationChange({conversationId: id, method: "PUT", body});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteConversationHistory(id: string) {
|
||||
try {
|
||||
await conversationRequest<void>(`/api/conversations/${encodeURIComponent(id)}`, {method: "DELETE"});
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
await queueConversationChange({conversationId: id, method: "DELETE"});
|
||||
}
|
||||
await removeCachedConversation(id);
|
||||
}
|
||||
|
||||
export async function flushPendingConversationChanges() {
|
||||
const pending = await listPendingConversationChanges();
|
||||
for (const change of pending) {
|
||||
try {
|
||||
await conversationRequest<void>(`/api/conversations/${encodeURIComponent(change.conversationId)}`, {
|
||||
method: change.method,
|
||||
body: change.body
|
||||
});
|
||||
await removePendingConversationChange(change.cacheKey);
|
||||
} catch (error) {
|
||||
if (error instanceof ConversationHttpError && change.method === "DELETE" && error.status === 404) {
|
||||
await removePendingConversationChange(change.cacheKey);
|
||||
continue;
|
||||
}
|
||||
if (isNetworkFailure(error)) break;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function synchronizeOfflineConversationHistory(summaries?: ConversationSummary[]) {
|
||||
const history = summaries || (await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations")).conversations;
|
||||
await cacheConversationSummaries(history);
|
||||
let cursor = 0;
|
||||
const worker = async () => {
|
||||
while (cursor < history.length) {
|
||||
const summary = history[cursor++];
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(summary.id)}`);
|
||||
await cacheConversation(payload.conversation);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({length: Math.min(4, history.length)}, () => worker()));
|
||||
return history;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {conversationHash, conversationIdFromHash} from "./conversation-hash.ts";
|
||||
|
||||
describe("conversation hash routing", () => {
|
||||
test("round-trips a conversation id", () => {
|
||||
const id = "f60dbe18-92ca-4a7d-9d5d-242d0ed4d042";
|
||||
expect(conversationIdFromHash(conversationHash(id))).toBe(id);
|
||||
});
|
||||
|
||||
test("preserves URL-sensitive ids", () => {
|
||||
expect(conversationIdFromHash(conversationHash("local/id + draft"))).toBe("local/id + draft");
|
||||
});
|
||||
|
||||
test("ignores unrelated or oversized hashes", () => {
|
||||
expect(conversationIdFromHash("#services")).toBe("");
|
||||
expect(conversationIdFromHash(`#conversation=${"x".repeat(121)}`)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
const conversationHashKey = "conversation";
|
||||
|
||||
export function conversationIdFromHash(hash: string) {
|
||||
const input = hash.startsWith("#") ? hash.slice(1) : hash;
|
||||
const id = new URLSearchParams(input).get(conversationHashKey)?.trim() || "";
|
||||
return id && id.length <= 120 ? id : "";
|
||||
}
|
||||
|
||||
export function conversationHash(id: string) {
|
||||
const parameters = new URLSearchParams();
|
||||
parameters.set(conversationHashKey, id);
|
||||
return `#${parameters.toString()}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {GenerationSettings} from "./generation-settings";
|
||||
|
||||
export type ResponseMetadata = {
|
||||
providerId: string;
|
||||
model: string;
|
||||
durationMs: number;
|
||||
outputTokens: number | null;
|
||||
tokensPerSecond: number | null;
|
||||
};
|
||||
|
||||
export type StoredChatMessage = {
|
||||
id: string;
|
||||
role: "system" | "user" | "assistant";
|
||||
parts: Array<Record<string, unknown> & {type: string}>;
|
||||
metadata?: {custom?: {response?: ResponseMetadata}};
|
||||
};
|
||||
|
||||
export type ConversationSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
providerId: string;
|
||||
model: string;
|
||||
messageCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Conversation = ConversationSummary & {
|
||||
generationSettings: GenerationSettings;
|
||||
messages: StoredChatMessage[];
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
import {randomUUID} from "node:crypto";
|
||||
import {mkdirSync} from "node:fs";
|
||||
import path from "node:path";
|
||||
import {Database} from "bun:sqlite";
|
||||
import {normalizeGenerationSettings, type GenerationSettings} from "./generation-settings";
|
||||
import type {ChatIdentity} from "./key-vault";
|
||||
import type {Conversation, ConversationSummary, ResponseMetadata, StoredChatMessage} from "./conversation-types";
|
||||
|
||||
const databasePath = process.env.CHAT_DATABASE_PATH || "/data/chat.db";
|
||||
let database: Database | undefined;
|
||||
|
||||
function getDatabase() {
|
||||
if (database) return database;
|
||||
mkdirSync(path.dirname(databasePath), {recursive: true});
|
||||
const opened = new Database(databasePath, {create: true, strict: true});
|
||||
opened.run(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
CREATE TABLE IF NOT EXISTS chat_conversation (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_issuer TEXT NOT NULL,
|
||||
owner_sub TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS chat_conversation_owner_updated
|
||||
ON chat_conversation (owner_issuer, owner_sub, updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS chat_message (
|
||||
conversation_id TEXT NOT NULL REFERENCES chat_conversation(id) ON DELETE CASCADE,
|
||||
id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant')),
|
||||
parts_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (conversation_id, id),
|
||||
UNIQUE (conversation_id, ordinal)
|
||||
);
|
||||
`);
|
||||
const columns = opened.query("PRAGMA table_info(chat_conversation)").all() as Array<{name: string}>;
|
||||
if (!columns.some((column) => column.name === "settings_json")) {
|
||||
opened.run("ALTER TABLE chat_conversation ADD COLUMN settings_json TEXT NOT NULL DEFAULT '{}'");
|
||||
}
|
||||
database = opened;
|
||||
return opened;
|
||||
}
|
||||
|
||||
type ConversationRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
settings_json: string;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type MessageRow = {id: string; role: StoredChatMessage["role"]; parts_json: string};
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string, maximum: number) {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
|
||||
return value.trim().slice(0, maximum);
|
||||
}
|
||||
|
||||
function conversationSummary(row: ConversationRow): ConversationSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
providerId: row.provider_id,
|
||||
model: row.model,
|
||||
messageCount: Number(row.message_count),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessages(value: unknown): StoredChatMessage[] {
|
||||
if (!Array.isArray(value) || value.length > 500) throw new Error("messages must contain at most 500 entries");
|
||||
return value.map((message, index) => {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error(`messages[${index}] is invalid`);
|
||||
const record = message as Record<string, unknown>;
|
||||
const role = record.role;
|
||||
if (!["system", "user", "assistant"].includes(String(role))) throw new Error(`messages[${index}].role is invalid`);
|
||||
if (!Array.isArray(record.parts)) throw new Error(`messages[${index}].parts is required`);
|
||||
const partsJson = JSON.stringify(record.parts);
|
||||
if (Buffer.byteLength(partsJson) > 1024 * 1024) throw new Error(`messages[${index}] is too large`);
|
||||
const metadataJson = JSON.stringify(record.metadata || {});
|
||||
if (Buffer.byteLength(metadataJson) > 64 * 1024) throw new Error(`messages[${index}].metadata is too large`);
|
||||
return {
|
||||
id: typeof record.id === "string" && record.id.trim() ? record.id.trim().slice(0, 160) : randomUUID(),
|
||||
role: role as StoredChatMessage["role"],
|
||||
parts: JSON.parse(partsJson) as StoredChatMessage["parts"],
|
||||
...(record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? {metadata: JSON.parse(metadataJson) as StoredChatMessage["metadata"]} : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function inferredTitle(messages: StoredChatMessage[]) {
|
||||
const userMessage = messages.find((message) => message.role === "user");
|
||||
const text = userMessage?.parts
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => String(part.text))
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text ? text.slice(0, 60) : "新对话";
|
||||
}
|
||||
|
||||
function ownedConversation(identity: ChatIdentity, id: string) {
|
||||
return getDatabase().query(`
|
||||
SELECT c.id, c.title, c.provider_id, c.model, c.settings_json, c.created_at, c.updated_at,
|
||||
(SELECT COUNT(*) FROM chat_message m WHERE m.conversation_id = c.id) AS message_count
|
||||
FROM chat_conversation c
|
||||
WHERE c.id = ? AND c.owner_issuer = ? AND c.owner_sub = ?
|
||||
`).get(id, identity.issuer, identity.sub) as ConversationRow | undefined;
|
||||
}
|
||||
|
||||
export function listConversations(identity: ChatIdentity): ConversationSummary[] {
|
||||
return (getDatabase().query(`
|
||||
SELECT c.id, c.title, c.provider_id, c.model, c.settings_json, c.created_at, c.updated_at,
|
||||
(SELECT COUNT(*) FROM chat_message m WHERE m.conversation_id = c.id) AS message_count
|
||||
FROM chat_conversation c
|
||||
WHERE c.owner_issuer = ? AND c.owner_sub = ?
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 100
|
||||
`).all(identity.issuer, identity.sub) as ConversationRow[]).map(conversationSummary);
|
||||
}
|
||||
|
||||
export function createConversation(identity: ChatIdentity, input: {providerId: unknown; model: unknown; generationSettings?: unknown}): Conversation {
|
||||
const providerId = requiredString(input.providerId, "providerId", 80);
|
||||
const model = requiredString(input.model, "model", 300);
|
||||
const generationSettings = normalizeGenerationSettings(input.generationSettings);
|
||||
const timestamp = now();
|
||||
const id = randomUUID();
|
||||
getDatabase().run("BEGIN IMMEDIATE");
|
||||
try {
|
||||
getDatabase().query(`
|
||||
DELETE FROM chat_conversation
|
||||
WHERE owner_issuer = ? AND owner_sub = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM chat_message WHERE conversation_id = chat_conversation.id)
|
||||
`).run(identity.issuer, identity.sub);
|
||||
getDatabase().query(`
|
||||
INSERT INTO chat_conversation (id, owner_issuer, owner_sub, title, provider_id, model, settings_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, '新对话', ?, ?, ?, ?, ?)
|
||||
`).run(id, identity.issuer, identity.sub, providerId, model, JSON.stringify(generationSettings), timestamp, timestamp);
|
||||
getDatabase().run("COMMIT");
|
||||
} catch (error) {
|
||||
getDatabase().run("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
return {...conversationSummary({id, title: "新对话", provider_id: providerId, model, settings_json: JSON.stringify(generationSettings), message_count: 0, created_at: timestamp, updated_at: timestamp}), generationSettings, messages: []};
|
||||
}
|
||||
|
||||
export function getConversation(identity: ChatIdentity, id: string): Conversation | null {
|
||||
const row = ownedConversation(identity, id);
|
||||
if (!row) return null;
|
||||
const messages = (getDatabase().query(`
|
||||
SELECT id, role, parts_json FROM chat_message WHERE conversation_id = ? ORDER BY ordinal
|
||||
`).all(id) as MessageRow[]).map((message) => {
|
||||
const parsed = JSON.parse(message.parts_json) as StoredChatMessage["parts"];
|
||||
const metadataPart = parsed.find((part) => part.type === "data-response-metadata" && part.data && typeof part.data === "object");
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
parts: parsed.filter((part) => part.type !== "data-response-metadata"),
|
||||
...(metadataPart ? {metadata: {custom: {response: metadataPart.data as ResponseMetadata}}} : {})
|
||||
};
|
||||
});
|
||||
return {...conversationSummary(row), generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")), messages};
|
||||
}
|
||||
|
||||
export function updateConversationSettings(identity: ChatIdentity, id: string, input: {providerId: unknown; model: unknown; generationSettings?: unknown}) {
|
||||
if (!ownedConversation(identity, id)) return false;
|
||||
const providerId = requiredString(input.providerId, "providerId", 80);
|
||||
const model = requiredString(input.model, "model", 300);
|
||||
const generationSettings = normalizeGenerationSettings(input.generationSettings);
|
||||
getDatabase().query(`
|
||||
UPDATE chat_conversation SET provider_id = ?, model = ?, settings_json = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`).run(providerId, model, JSON.stringify(generationSettings), now(), id, identity.issuer, identity.sub);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function saveConversationMessages(identity: ChatIdentity, id: string, input: {
|
||||
providerId: unknown;
|
||||
model: unknown;
|
||||
messages: unknown;
|
||||
}) {
|
||||
const existing = ownedConversation(identity, id);
|
||||
if (!existing) return null;
|
||||
const providerId = requiredString(input.providerId, "providerId", 80);
|
||||
const model = requiredString(input.model, "model", 300);
|
||||
const messages = normalizeMessages(input.messages);
|
||||
const timestamp = now();
|
||||
const title = existing.title === "新对话" ? inferredTitle(messages) : existing.title;
|
||||
getDatabase().run("BEGIN IMMEDIATE");
|
||||
try {
|
||||
getDatabase().query("DELETE FROM chat_message WHERE conversation_id = ?").run(id);
|
||||
const insert = getDatabase().query(`
|
||||
INSERT INTO chat_message (conversation_id, id, ordinal, role, parts_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
messages.forEach((message, ordinal) => {
|
||||
const parts = message.metadata?.custom?.response
|
||||
? [...message.parts, {type: "data-response-metadata", data: message.metadata.custom.response}]
|
||||
: message.parts;
|
||||
insert.run(id, message.id, ordinal, message.role, JSON.stringify(parts), timestamp);
|
||||
});
|
||||
getDatabase().query(`
|
||||
UPDATE chat_conversation SET title = ?, provider_id = ?, model = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`).run(title, providerId, model, timestamp, id, identity.issuer, identity.sub);
|
||||
getDatabase().run("COMMIT");
|
||||
} catch (error) {
|
||||
getDatabase().run("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
return getConversation(identity, id);
|
||||
}
|
||||
|
||||
export function deleteConversation(identity: ChatIdentity, id: string) {
|
||||
const result = getDatabase().query(`
|
||||
DELETE FROM chat_conversation WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
|
||||
`).run(id, identity.issuer, identity.sub);
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {generationCallOptions, normalizeGenerationSettings} from "./generation-settings.ts";
|
||||
|
||||
function provider(api) {
|
||||
return {
|
||||
id: api,
|
||||
name: api,
|
||||
api,
|
||||
connection: {type: "backend", baseUrl: "https://example.com/v1", proxy: null},
|
||||
auth: {type: "bearer"},
|
||||
headers: {},
|
||||
defaultModel: "test-model",
|
||||
discovery: {type: "openai-models-list", url: "https://example.com/v1/models"},
|
||||
builtin: false,
|
||||
credentialState: "configured",
|
||||
credentials: []
|
||||
};
|
||||
}
|
||||
|
||||
describe("generation settings", () => {
|
||||
test("normalizes user-controlled values", () => {
|
||||
expect(normalizeGenerationSettings({reasoning: "high", showReasoningSummary: true, temperature: 5, maxOutputTokens: 12.8})).toEqual({
|
||||
reasoning: "high",
|
||||
showReasoningSummary: true,
|
||||
temperature: 2,
|
||||
maxOutputTokens: 12
|
||||
});
|
||||
});
|
||||
|
||||
test("maps OpenAI reasoning and summary", () => {
|
||||
expect(generationCallOptions(provider("openai-responses"), {
|
||||
reasoning: "high",
|
||||
showReasoningSummary: true,
|
||||
temperature: null,
|
||||
maxOutputTokens: 4096
|
||||
})).toEqual({
|
||||
reasoning: "high",
|
||||
maxOutputTokens: 4096,
|
||||
providerOptions: {openai: {reasoningSummary: "auto"}}
|
||||
});
|
||||
});
|
||||
|
||||
test("maps Anthropic adaptive thinking", () => {
|
||||
expect(generationCallOptions(provider("anthropic-messages"), {
|
||||
reasoning: "medium",
|
||||
showReasoningSummary: true,
|
||||
temperature: 0.4,
|
||||
maxOutputTokens: null
|
||||
})).toEqual({
|
||||
reasoning: "medium",
|
||||
temperature: 0.4,
|
||||
providerOptions: {anthropic: {thinking: {type: "adaptive", display: "summarized"}}}
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps auto mode provider-default", () => {
|
||||
expect(generationCallOptions(provider("openai-completions"), {
|
||||
reasoning: "auto",
|
||||
showReasoningSummary: false,
|
||||
temperature: null,
|
||||
maxOutputTokens: null
|
||||
})).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {ProviderDefinition} from "./provider-types";
|
||||
|
||||
type JsonValue = null | string | number | boolean | JsonValue[] | {[key: string]: JsonValue};
|
||||
|
||||
export type ReasoningLevel = "auto" | "none" | "low" | "medium" | "high";
|
||||
|
||||
export type GenerationSettings = {
|
||||
reasoning: ReasoningLevel;
|
||||
showReasoningSummary: boolean;
|
||||
temperature: number | null;
|
||||
maxOutputTokens: number | null;
|
||||
};
|
||||
|
||||
export const defaultGenerationSettings: GenerationSettings = {
|
||||
reasoning: "auto",
|
||||
showReasoningSummary: false,
|
||||
temperature: null,
|
||||
maxOutputTokens: null
|
||||
};
|
||||
|
||||
export function normalizeGenerationSettings(value: unknown): GenerationSettings {
|
||||
const input = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const reasoning = ["auto", "none", "low", "medium", "high"].includes(String(input.reasoning))
|
||||
? input.reasoning as ReasoningLevel
|
||||
: "auto";
|
||||
const temperature = typeof input.temperature === "number" && Number.isFinite(input.temperature)
|
||||
? Math.min(2, Math.max(0, input.temperature))
|
||||
: null;
|
||||
const maxOutputTokens = typeof input.maxOutputTokens === "number" && Number.isFinite(input.maxOutputTokens)
|
||||
? Math.min(1_000_000, Math.max(1, Math.floor(input.maxOutputTokens)))
|
||||
: null;
|
||||
return {
|
||||
reasoning,
|
||||
showReasoningSummary: input.showReasoningSummary === true,
|
||||
temperature,
|
||||
maxOutputTokens
|
||||
};
|
||||
}
|
||||
|
||||
export function generationCallOptions(provider: ProviderDefinition, settings: GenerationSettings) {
|
||||
const providerOptions: Record<string, {[key: string]: JsonValue}> = {};
|
||||
if (provider.api === "openai-responses") {
|
||||
providerOptions.openai = {reasoningSummary: settings.showReasoningSummary ? "auto" : null};
|
||||
} else if (provider.api === "anthropic-messages" && settings.showReasoningSummary && settings.reasoning !== "none") {
|
||||
providerOptions.anthropic = {thinking: {type: "adaptive", display: "summarized"}};
|
||||
} else if (provider.api === "google-generative-ai" && settings.showReasoningSummary) {
|
||||
providerOptions.google = {thinkingConfig: {includeThoughts: true}};
|
||||
}
|
||||
return {
|
||||
...(settings.reasoning !== "auto" ? {reasoning: settings.reasoning} : {}),
|
||||
...(settings.temperature !== null ? {temperature: settings.temperature} : {}),
|
||||
...(settings.maxOutputTokens !== null ? {maxOutputTokens: settings.maxOutputTokens} : {}),
|
||||
...(Object.keys(providerOptions).length ? {providerOptions} : {})
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {readFileSync} from "node:fs";
|
||||
|
||||
export type ChatIdentity = {issuer: string; sub: string; username: string; name: string; email: string};
|
||||
type HeaderReader = Pick<Headers, "get">;
|
||||
|
||||
const keyVaultUrl = process.env.KEY_VAULT_URL || "http://ai-gateway:8093";
|
||||
const authentikIssuer = process.env.AUTHENTIK_ISSUER || "https://auth.xiteng.site";
|
||||
const serviceTokenFile = process.env.KEY_VAULT_TOKEN_FILE || "/run/secrets/portal_gateway_hmac";
|
||||
let serviceToken: string | null = null;
|
||||
|
||||
function loadServiceToken() {
|
||||
if (!serviceToken) serviceToken = readFileSync(/* turbopackIgnore: true */ serviceTokenFile, "utf8").trim();
|
||||
return serviceToken;
|
||||
}
|
||||
|
||||
export function identityFromHeaders(headers: HeaderReader): ChatIdentity {
|
||||
const username = headers.get("x-authentik-username")?.trim() || "";
|
||||
const sub = headers.get("x-authentik-uid")?.trim() || "";
|
||||
if (!username || !sub) {
|
||||
const error = new Error("Authenticated user context is required");
|
||||
Object.assign(error, {statusCode: 401});
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
issuer: authentikIssuer,
|
||||
sub,
|
||||
username,
|
||||
name: headers.get("x-authentik-name")?.trim() || username,
|
||||
email: headers.get("x-authentik-email")?.trim() || ""
|
||||
};
|
||||
}
|
||||
|
||||
export function keyVaultFetch(
|
||||
pathname: string,
|
||||
identity: ChatIdentity,
|
||||
init: {method?: string; body?: string; headers?: HeadersInit} = {}
|
||||
) {
|
||||
const method = init.method || "GET";
|
||||
return fetch(new URL(pathname, keyVaultUrl), {
|
||||
method,
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Authorization": `Bearer ${loadServiceToken()}`,
|
||||
...init.headers,
|
||||
"X-Key-Vault-Actor-Issuer": identity.issuer,
|
||||
"X-Key-Vault-Actor-Sub": identity.sub,
|
||||
"X-Key-Vault-Actor-Username": identity.username
|
||||
},
|
||||
body: init.body,
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(300000)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {applyBrowserProviderSettings} from "./browser-provider-settings.ts";
|
||||
import {discoverProviderModels} from "./provider-connectivity.ts";
|
||||
|
||||
function llamaProvider() {
|
||||
return {
|
||||
id: "llama.cpp",
|
||||
name: "llama.cpp",
|
||||
api: "openai-completions",
|
||||
connection: {type: "frontend", baseUrl: "http://127.0.0.1:8080/v1", proxy: null},
|
||||
auth: {type: "none"},
|
||||
headers: {},
|
||||
defaultModel: "local-model",
|
||||
discovery: {type: "openai-models-list", url: "http://127.0.0.1:8080/v1/models"},
|
||||
builtin: true,
|
||||
credentialState: "local",
|
||||
credentials: []
|
||||
};
|
||||
}
|
||||
|
||||
describe("llama.cpp discovery", () => {
|
||||
test("applies a browser-local endpoint override", () => {
|
||||
const provider = applyBrowserProviderSettings(llamaProvider(), {provider: {baseUrl: "http://192.168.4.20:8081/v1/"}});
|
||||
expect(provider.connection.baseUrl).toBe("http://192.168.4.20:8081/v1");
|
||||
expect(provider.discovery.url).toBe("http://192.168.4.20:8081/v1/models");
|
||||
});
|
||||
|
||||
test("falls back to /props when /v1/models is unavailable", async () => {
|
||||
const requests = [];
|
||||
const result = await discoverProviderModels(llamaProvider(), {}, async (input) => {
|
||||
requests.push(String(input));
|
||||
if (String(input).endsWith("/v1/models")) {
|
||||
return new Response(JSON.stringify({error: "Not Found"}), {status: 404, headers: {"Content-Type": "application/json"}});
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
model_path: "/models/Qwen3.5-9B-Q4_K_M.gguf",
|
||||
default_generation_settings: {n_ctx: 32768}
|
||||
}), {status: 200, headers: {"Content-Type": "application/json"}});
|
||||
});
|
||||
expect(requests).toEqual(["http://127.0.0.1:8080/v1/models", "http://127.0.0.1:8080/props"]);
|
||||
expect(result.endpoint).toBe("http://127.0.0.1:8080/props");
|
||||
expect(result.models).toEqual([{id: "Qwen3.5-9B-Q4_K_M.gguf", name: "Qwen3.5-9B-Q4_K_M.gguf", ownedBy: "llamacpp", contextWindow: 32768}]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {ProviderSecret} from "./provider-types";
|
||||
|
||||
export type LocalCredential = {
|
||||
id: string;
|
||||
providerId: string;
|
||||
name: string;
|
||||
secret: ProviderSecret;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const databaseName = "xiteng-chat-local-vault";
|
||||
const storeName = "credentials";
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(storeName)) database.createObjectStore(storeName, {keyPath: "id"});
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error("Unable to open local credential store"));
|
||||
});
|
||||
}
|
||||
|
||||
async function transaction<T>(mode: IDBTransactionMode, operation: (store: IDBObjectStore) => IDBRequest<T>) {
|
||||
const database = await openDatabase();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const request = operation(database.transaction(storeName, mode).objectStore(storeName));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error("Local credential operation failed"));
|
||||
}).finally(() => database.close());
|
||||
}
|
||||
|
||||
export function localCredentialId(providerId: string, name = "default") {
|
||||
return `${providerId}/${name}`;
|
||||
}
|
||||
|
||||
export function getLocalCredential(providerId: string, name = "default") {
|
||||
return transaction<LocalCredential | undefined>("readonly", (store) => store.get(localCredentialId(providerId, name)));
|
||||
}
|
||||
|
||||
export async function listLocalCredentials() {
|
||||
return transaction<LocalCredential[]>("readonly", (store) => store.getAll());
|
||||
}
|
||||
|
||||
export async function saveLocalCredential(providerId: string, name: string, secret: ProviderSecret) {
|
||||
const id = localCredentialId(providerId, name);
|
||||
const existing = await getLocalCredential(providerId, name);
|
||||
const timestamp = new Date().toISOString();
|
||||
const credential: LocalCredential = {
|
||||
id,
|
||||
providerId,
|
||||
name,
|
||||
secret,
|
||||
createdAt: existing?.createdAt || timestamp,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
await transaction("readwrite", (store) => store.put(credential));
|
||||
return credential;
|
||||
}
|
||||
|
||||
export function deleteLocalCredential(providerId: string, name = "default") {
|
||||
return transaction("readwrite", (store) => store.delete(localCredentialId(providerId, name)));
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type {Conversation, ConversationSummary} from "./conversation-types";
|
||||
|
||||
const databaseName = "xiteng-chat-offline";
|
||||
const databaseVersion = 1;
|
||||
const activeProfileKey = "xiteng-chat-offline-profile";
|
||||
|
||||
type CachedProfile<T = unknown> = {
|
||||
id: string;
|
||||
config: T;
|
||||
summaries: ConversationSummary[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type CachedConversation = Conversation & {cacheKey: string; profileId: string};
|
||||
|
||||
export type PendingConversationChange = {
|
||||
cacheKey: string;
|
||||
profileId: string;
|
||||
conversationId: string;
|
||||
method: "PUT" | "PATCH" | "DELETE";
|
||||
body?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, databaseVersion);
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains("profiles")) database.createObjectStore("profiles", {keyPath: "id"});
|
||||
if (!database.objectStoreNames.contains("conversations")) {
|
||||
const conversations = database.createObjectStore("conversations", {keyPath: "cacheKey"});
|
||||
conversations.createIndex("profileId", "profileId");
|
||||
}
|
||||
if (!database.objectStoreNames.contains("pending")) {
|
||||
const pending = database.createObjectStore("pending", {keyPath: "cacheKey"});
|
||||
pending.createIndex("profileId", "profileId");
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error("Unable to open offline history"));
|
||||
});
|
||||
}
|
||||
|
||||
async function transaction<T>(storeName: string, mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>) {
|
||||
const database = await openDatabase();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const current = database.transaction(storeName, mode);
|
||||
const request = run(current.objectStore(storeName));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error(`Offline ${storeName} operation failed`));
|
||||
current.oncomplete = () => database.close();
|
||||
current.onerror = () => reject(current.error || new Error(`Offline ${storeName} transaction failed`));
|
||||
});
|
||||
}
|
||||
|
||||
function activeProfileId() {
|
||||
return window.localStorage.getItem(activeProfileKey) || "";
|
||||
}
|
||||
|
||||
function conversationCacheKey(profileId: string, conversationId: string) {
|
||||
return `${profileId}:${conversationId}`;
|
||||
}
|
||||
|
||||
export function activateOfflineProfile(profileId: string) {
|
||||
window.localStorage.setItem(activeProfileKey, profileId);
|
||||
}
|
||||
|
||||
export async function cacheChatConfig<T>(profileId: string, config: T) {
|
||||
activateOfflineProfile(profileId);
|
||||
const current = await transaction<CachedProfile<T> | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
||||
const profile: CachedProfile<T> = {
|
||||
id: profileId,
|
||||
config,
|
||||
summaries: current?.summaries || [],
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put(profile));
|
||||
}
|
||||
|
||||
export async function loadCachedChatConfig<T>() {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return null;
|
||||
const profile = await transaction<CachedProfile<T> | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
||||
return profile ? {profileId, config: profile.config, updatedAt: profile.updatedAt} : null;
|
||||
}
|
||||
|
||||
export async function cacheConversationSummaries(summaries: ConversationSummary[]) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
const current = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
||||
if (!current) return;
|
||||
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put({
|
||||
...current,
|
||||
summaries,
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
export async function loadCachedConversationSummaries() {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return [];
|
||||
const profile = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
|
||||
return profile?.summaries || [];
|
||||
}
|
||||
|
||||
export async function cacheConversation(conversation: Conversation) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
const record: CachedConversation = {
|
||||
...conversation,
|
||||
cacheKey: conversationCacheKey(profileId, conversation.id),
|
||||
profileId
|
||||
};
|
||||
await transaction<IDBValidKey>("conversations", "readwrite", (store) => store.put(record));
|
||||
const summaries = await loadCachedConversationSummaries();
|
||||
const summary: ConversationSummary = {
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
providerId: conversation.providerId,
|
||||
model: conversation.model,
|
||||
messageCount: conversation.messageCount,
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt
|
||||
};
|
||||
await cacheConversationSummaries([summary, ...summaries.filter((item) => item.id !== conversation.id)].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
|
||||
}
|
||||
|
||||
export async function loadCachedConversation(id: string) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return null;
|
||||
const record = await transaction<CachedConversation | undefined>("conversations", "readonly", (store) => store.get(conversationCacheKey(profileId, id)));
|
||||
if (!record) return null;
|
||||
const {cacheKey: _cacheKey, profileId: _profileId, ...conversation} = record;
|
||||
return conversation;
|
||||
}
|
||||
|
||||
export async function removeCachedConversation(id: string) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
await transaction<undefined>("conversations", "readwrite", (store) => store.delete(conversationCacheKey(profileId, id)));
|
||||
const summaries = await loadCachedConversationSummaries();
|
||||
await cacheConversationSummaries(summaries.filter((conversation) => conversation.id !== id));
|
||||
}
|
||||
|
||||
export async function queueConversationChange(change: Omit<PendingConversationChange, "cacheKey" | "profileId" | "createdAt">) {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return;
|
||||
const record: PendingConversationChange = {
|
||||
...change,
|
||||
cacheKey: `${profileId}:${change.conversationId}:${change.method}`,
|
||||
profileId,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
await transaction<IDBValidKey>("pending", "readwrite", (store) => store.put(record));
|
||||
}
|
||||
|
||||
export async function listPendingConversationChanges() {
|
||||
const profileId = activeProfileId();
|
||||
if (!profileId) return [];
|
||||
const database = await openDatabase();
|
||||
return new Promise<PendingConversationChange[]>((resolve, reject) => {
|
||||
const current = database.transaction("pending", "readonly");
|
||||
const request = current.objectStore("pending").index("profileId").getAll(profileId);
|
||||
request.onsuccess = () => resolve(request.result.sort((left, right) => left.createdAt.localeCompare(right.createdAt)));
|
||||
request.onerror = () => reject(request.error || new Error("Unable to read pending history changes"));
|
||||
current.oncomplete = () => database.close();
|
||||
});
|
||||
}
|
||||
|
||||
export async function removePendingConversationChange(cacheKey: string) {
|
||||
await transaction<undefined>("pending", "readwrite", (store) => store.delete(cacheKey));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type ChatProfile = {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import {applyProviderAuthentication} from "./provider-model";
|
||||
import type {ProviderDefinition, ProviderModel, ProviderSecret} from "./provider-types";
|
||||
|
||||
type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
|
||||
function accountEndpoints(provider: ProviderDefinition) {
|
||||
const baseUrl = provider.connection.baseUrl.replace(/\/+$/, "");
|
||||
const url = new URL(baseUrl);
|
||||
if (url.hostname === "openrouter.ai") return ["https://openrouter.ai/api/v1/auth/key"];
|
||||
if (url.hostname === "api.deepseek.com") return [`${url.origin}/user/balance`];
|
||||
if (url.hostname === "api.moonshot.cn") return [`${baseUrl}/users/me/balance`];
|
||||
if (url.hostname === "api.openai.com") return [`${baseUrl}/dashboard/billing/credit_grants`];
|
||||
if (["openai-completions", "openai-responses"].includes(provider.api)) return [`${baseUrl}/dashboard/billing/credit_grants`];
|
||||
return [];
|
||||
}
|
||||
|
||||
async function responseText(response: Response, maximum = 4 * 1024 * 1024) {
|
||||
const text = await response.text();
|
||||
if (text.length > maximum) throw new Error("Provider response is too large");
|
||||
return text;
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown): JsonRecord | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null;
|
||||
}
|
||||
|
||||
function publicMetadata(value: unknown, depth = 0): unknown {
|
||||
if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value;
|
||||
if (depth >= 3) return undefined;
|
||||
if (Array.isArray(value)) return value.slice(0, 20).map((item) => publicMetadata(item, depth + 1)).filter((item) => item !== undefined);
|
||||
const record = jsonRecord(value);
|
||||
if (!record) return undefined;
|
||||
return Object.fromEntries(Object.entries(record).slice(0, 50).map(([key, item]) => [key, publicMetadata(item, depth + 1)]).filter((entry) => entry[1] !== undefined));
|
||||
}
|
||||
|
||||
function selectedMetadata(record: JsonRecord, pattern: RegExp) {
|
||||
return Object.fromEntries(Object.entries(record).filter(([key]) => pattern.test(key)).map(([key, value]) => [key, publicMetadata(value)]).filter((entry) => entry[1] !== undefined));
|
||||
}
|
||||
|
||||
export function normalizeDiscoveredModels(payload: unknown): ProviderModel[] {
|
||||
const root = jsonRecord(payload);
|
||||
if (!root) return [];
|
||||
const source = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : [];
|
||||
return source.slice(0, 300).map((value) => {
|
||||
const model = jsonRecord(value);
|
||||
if (!model) return null;
|
||||
const rawId = model.id || model.name || model.model;
|
||||
if (typeof rawId !== "string" || !rawId.trim()) return null;
|
||||
const id = rawId.replace(/^models\//, "");
|
||||
const pricing = selectedMetadata(model, /price|pricing|cost|rate|token/i);
|
||||
return {
|
||||
id,
|
||||
name: typeof model.displayName === "string" ? model.displayName : typeof model.name === "string" ? model.name.replace(/^models\//, "") : id,
|
||||
...(typeof model.owned_by === "string" ? {ownedBy: model.owned_by} : {}),
|
||||
...(Number.isFinite(model.context_length) ? {contextWindow: Number(model.context_length)} : {}),
|
||||
...(Object.keys(pricing).length ? {pricing} : {})
|
||||
};
|
||||
}).filter((model): model is ProviderModel => model !== null);
|
||||
}
|
||||
|
||||
function normalizeLlamaCppProps(payload: unknown, fallbackModel: string): ProviderModel[] {
|
||||
const root = jsonRecord(payload);
|
||||
if (!root) return [];
|
||||
const modelPath = typeof root.model_path === "string" ? root.model_path : "";
|
||||
const modelAlias = typeof root.model_alias === "string" ? root.model_alias : "";
|
||||
const id = modelAlias.trim() || modelPath.split(/[\\/]/).filter(Boolean).at(-1) || fallbackModel;
|
||||
const generationSettings = jsonRecord(root.default_generation_settings);
|
||||
const contextWindow = Number(generationSettings?.n_ctx);
|
||||
return [{
|
||||
id,
|
||||
name: id,
|
||||
ownedBy: "llamacpp",
|
||||
...(Number.isFinite(contextWindow) && contextWindow > 0 ? {contextWindow} : {})
|
||||
}];
|
||||
}
|
||||
|
||||
function responseRateLimits(response: Response) {
|
||||
return Object.fromEntries([...response.headers.entries()].filter(([name]) => /rate.?limit|retry-after|quota/i.test(name)));
|
||||
}
|
||||
|
||||
async function fetchJson(providerFetch: ProviderFetch, endpoint: string, headers: Headers, timeoutMs = 15000) {
|
||||
const response = await providerFetch(endpoint, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
const text = await responseText(response);
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = {preview: text.slice(0, 1000)};
|
||||
}
|
||||
return {response, payload};
|
||||
}
|
||||
|
||||
async function modelProbe(provider: ProviderDefinition, secret: ProviderSecret, providerFetch: ProviderFetch) {
|
||||
const endpoint = provider.discovery.url;
|
||||
const headers = applyProviderAuthentication(provider, secret, {
|
||||
"Accept": "application/json",
|
||||
...(provider.discovery.type === "anthropic-models-list" ? {"anthropic-version": "2023-06-01"} : {})
|
||||
});
|
||||
const startedAt = performance.now();
|
||||
const initial = await fetchJson(providerFetch, endpoint, headers);
|
||||
let {response, payload} = initial;
|
||||
let resolvedEndpoint = endpoint;
|
||||
let models = response.ok ? normalizeDiscoveredModels(payload) : [];
|
||||
if (provider.id === "llama.cpp" && models.length === 0) {
|
||||
const propsEndpoint = `${provider.connection.baseUrl.replace(/\/v1\/?$/, "")}/props`;
|
||||
try {
|
||||
const props = await fetchJson(providerFetch, propsEndpoint, headers, 5000);
|
||||
const propsModels = props.response.ok ? normalizeLlamaCppProps(props.payload, provider.defaultModel) : [];
|
||||
if (propsModels.length > 0) {
|
||||
response = props.response;
|
||||
payload = props.payload;
|
||||
resolvedEndpoint = propsEndpoint;
|
||||
models = propsModels;
|
||||
}
|
||||
} catch {
|
||||
// Preserve the primary /v1/models error when the compatibility probe also fails.
|
||||
}
|
||||
}
|
||||
const latencyMs = Math.max(0, Math.round(performance.now() - startedAt));
|
||||
if (!response.ok) {
|
||||
const root = jsonRecord(payload);
|
||||
const detail = root?.preview || root?.error || root?.message;
|
||||
const error = new Error(typeof detail === "string" ? detail : `Provider HTTP ${response.status}`);
|
||||
Object.assign(error, {statusCode: response.status});
|
||||
throw error;
|
||||
}
|
||||
return {endpoint: resolvedEndpoint, headers, response, payload, latencyMs, models};
|
||||
}
|
||||
|
||||
export async function discoverProviderModels(
|
||||
provider: ProviderDefinition,
|
||||
secret: ProviderSecret,
|
||||
providerFetch: ProviderFetch = fetch
|
||||
) {
|
||||
const probe = await modelProbe(provider, secret, providerFetch);
|
||||
return {
|
||||
endpoint: probe.endpoint,
|
||||
status: probe.response.status,
|
||||
latencyMs: probe.latencyMs,
|
||||
models: probe.models
|
||||
};
|
||||
}
|
||||
|
||||
export async function testProviderConnectivity(
|
||||
provider: ProviderDefinition,
|
||||
secret: ProviderSecret,
|
||||
providerFetch: ProviderFetch = fetch
|
||||
) {
|
||||
const probe = await modelProbe(provider, secret, providerFetch);
|
||||
const {endpoint, headers, response, payload, latencyMs, models} = probe;
|
||||
const root = jsonRecord(payload) || {};
|
||||
const account = selectedMetadata(root, /balance|credit|quota|usage|limit|billing|currency/i);
|
||||
const rateLimits = responseRateLimits(response);
|
||||
const accountUrls = accountEndpoints(provider);
|
||||
let accountProbe: unknown = null;
|
||||
for (const accountUrl of accountUrls) {
|
||||
try {
|
||||
const probe = await fetchJson(providerFetch, accountUrl, headers, 5000);
|
||||
if (!probe.response.ok) continue;
|
||||
accountProbe = {
|
||||
endpoint: accountUrl,
|
||||
status: probe.response.status,
|
||||
data: publicMetadata(jsonRecord(probe.payload)?.data || probe.payload)
|
||||
};
|
||||
break;
|
||||
} catch {
|
||||
// Account metadata is best-effort and must not fail a successful model probe.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
latencyMs,
|
||||
endpoint,
|
||||
modelCount: models.length,
|
||||
models,
|
||||
account: Object.keys(account).length ? account : null,
|
||||
accountProbe,
|
||||
rateLimits: Object.keys(rateLimits).length ? rateLimits : null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
|
||||
|
||||
type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export function applyProviderAuthentication(
|
||||
provider: ProviderDefinition,
|
||||
secret: ProviderSecret,
|
||||
inputHeaders?: HeadersInit
|
||||
) {
|
||||
const headers = new Headers(inputHeaders);
|
||||
headers.delete("authorization");
|
||||
headers.delete("x-api-key");
|
||||
headers.delete("x-goog-api-key");
|
||||
for (const [name, value] of Object.entries(provider.headers || {})) headers.set(name, value);
|
||||
for (const [name, value] of Object.entries(secret.provider?.headers || {})) headers.set(name, value);
|
||||
const apiKey = secret.provider?.apiKey || "";
|
||||
if (provider.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
if (provider.auth.type === "header" && provider.auth.header && apiKey) headers.set(provider.auth.header, apiKey);
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function createProviderModel(
|
||||
provider: ProviderDefinition,
|
||||
secret: ProviderSecret,
|
||||
modelId: string,
|
||||
providerFetch: ProviderFetch
|
||||
) {
|
||||
const authenticatedFetch: ProviderFetch = (input, init = {}) => providerFetch(input, {
|
||||
...init,
|
||||
headers: applyProviderAuthentication(provider, secret, init.headers)
|
||||
});
|
||||
// Provider API comes from the runtime Registry; dynamic imports keep unused SDKs out of the initial client chunk.
|
||||
if (provider.api === "openai-completions") {
|
||||
const {createOpenAICompatible} = await import("@ai-sdk/openai-compatible");
|
||||
return createOpenAICompatible({
|
||||
name: provider.id,
|
||||
baseURL: provider.connection.baseUrl,
|
||||
fetch: authenticatedFetch as typeof fetch
|
||||
}).chatModel(modelId);
|
||||
}
|
||||
if (provider.api === "openai-responses") {
|
||||
const {createOpenAI} = await import("@ai-sdk/openai");
|
||||
return createOpenAI({
|
||||
name: provider.id,
|
||||
baseURL: provider.connection.baseUrl,
|
||||
apiKey: secret.provider?.apiKey || "browser-managed",
|
||||
fetch: authenticatedFetch as typeof fetch
|
||||
}).responses(modelId);
|
||||
}
|
||||
if (provider.api === "anthropic-messages") {
|
||||
const {createAnthropic} = await import("@ai-sdk/anthropic");
|
||||
return createAnthropic({
|
||||
name: provider.id,
|
||||
baseURL: provider.connection.baseUrl,
|
||||
apiKey: secret.provider?.apiKey || "browser-managed",
|
||||
fetch: authenticatedFetch as typeof fetch
|
||||
}).messages(modelId);
|
||||
}
|
||||
const {createGoogleGenerativeAI} = await import("@ai-sdk/google");
|
||||
return createGoogleGenerativeAI({
|
||||
name: provider.id,
|
||||
baseURL: provider.connection.baseUrl,
|
||||
apiKey: secret.provider?.apiKey || "browser-managed",
|
||||
fetch: authenticatedFetch as typeof fetch
|
||||
}).chat(modelId);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type ProviderApi = "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai";
|
||||
|
||||
export type ProviderModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
input?: string[];
|
||||
reasoning?: boolean;
|
||||
ownedBy?: string;
|
||||
pricing?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ProviderDiscovery = {
|
||||
type: "openai-models-list" | "anthropic-models-list" | "google-models-list";
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ProviderDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
api: ProviderApi;
|
||||
connection: {
|
||||
type: "frontend" | "backend";
|
||||
baseUrl: string;
|
||||
proxy: null | {type: "relay" | "http" | "https" | "socks5"; url: string};
|
||||
};
|
||||
auth: {type: "bearer" | "header" | "none"; header?: string};
|
||||
headers: Record<string, string>;
|
||||
defaultModel: string;
|
||||
discovery: ProviderDiscovery;
|
||||
builtin: boolean;
|
||||
credentialState: "configured" | "missing" | "local";
|
||||
credentials: Array<{id: string; providerId: string; name: string; fingerprint: string}>;
|
||||
};
|
||||
|
||||
export type ProviderSecret = {
|
||||
provider?: {apiKey?: string; headers?: Record<string, string>; baseUrl?: string};
|
||||
proxy?: {username?: string; password?: string; token?: string};
|
||||
};
|
||||
|
||||
export type ResolvedBackendProvider = {
|
||||
provider: ProviderDefinition;
|
||||
credential: {id: string; name: string; secret: ProviderSecret};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import {describe, expect, test} from "bun:test";
|
||||
import {responseMetadata} from "./response-metadata.ts";
|
||||
|
||||
describe("response metadata", () => {
|
||||
test("calculates output token throughput", () => {
|
||||
const metadata = responseMetadata("rust.cat", "gpt-test", performance.now() - 2000, 40);
|
||||
expect(metadata.providerId).toBe("rust.cat");
|
||||
expect(metadata.model).toBe("gpt-test");
|
||||
expect(metadata.durationMs).toBeGreaterThanOrEqual(1900);
|
||||
expect(metadata.tokensPerSecond).toBeGreaterThanOrEqual(19);
|
||||
expect(metadata.tokensPerSecond).toBeLessThanOrEqual(21);
|
||||
});
|
||||
|
||||
test("keeps speed unavailable when provider omits token usage", () => {
|
||||
const metadata = responseMetadata("local", "model", performance.now() - 100, undefined);
|
||||
expect(metadata.outputTokens).toBeNull();
|
||||
expect(metadata.tokensPerSecond).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {ResponseMetadata} from "./conversation-types";
|
||||
|
||||
export function responseMetadata(providerId: string, model: string, startedAt: number, outputTokens: number | undefined | null): ResponseMetadata {
|
||||
const durationMs = Math.max(1, Math.round(performance.now() - startedAt));
|
||||
const normalizedTokens = typeof outputTokens === "number" && Number.isFinite(outputTokens) && outputTokens >= 0
|
||||
? Math.round(outputTokens)
|
||||
: null;
|
||||
const tokensPerSecond = normalizedTokens === null
|
||||
? null
|
||||
: Math.round((normalizedTokens / (durationMs / 1000)) * 10) / 10;
|
||||
return {providerId, model, durationMs, outputTokens: normalizedTokens, tokensPerSecond};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import nodeFetch from "node-fetch";
|
||||
import {ProxyAgent} from "proxy-agent";
|
||||
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
|
||||
|
||||
export function createServerProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) {
|
||||
const proxy = provider.connection.proxy;
|
||||
if (!proxy) return fetch;
|
||||
const proxyUrl = new URL(proxy.url);
|
||||
if (secret.proxy?.username) proxyUrl.username = secret.proxy.username;
|
||||
if (secret.proxy?.password) proxyUrl.password = secret.proxy.password;
|
||||
const agent = new ProxyAgent({getProxyForUrl: () => proxyUrl.toString()});
|
||||
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await nodeFetch(String(input), {...init, agent} as never);
|
||||
return response as unknown as Response;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user