285 lines
7.3 KiB
TypeScript
285 lines
7.3 KiB
TypeScript
// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
|
|
// Copyright 2025 TimeHexOn & foxhop & russell@unturf
|
|
// https://www.permacomputer.com
|
|
|
|
/**
|
|
* UncloseAI Deno/TypeScript Library
|
|
* OpenAI-compatible API client with streaming support
|
|
* Compatible with vLLM, Ollama, and OpenAI-compatible endpoints
|
|
*/
|
|
|
|
interface ModelInfo {
|
|
id: string;
|
|
endpoint: string;
|
|
maxTokens: number;
|
|
}
|
|
|
|
interface Message {
|
|
role: string;
|
|
content: string;
|
|
}
|
|
|
|
interface ChatOptions {
|
|
modelIdx?: number;
|
|
maxTokens?: number;
|
|
temperature?: number;
|
|
}
|
|
|
|
/**
|
|
* UncloseAI Client class
|
|
*/
|
|
export class UncloseAI {
|
|
public models: ModelInfo[] = [];
|
|
public ttsEndpoints: string[] = [];
|
|
private timeout: number;
|
|
|
|
constructor(timeout = 30000) {
|
|
this.timeout = timeout;
|
|
this.discoverModels();
|
|
}
|
|
|
|
/**
|
|
* Discover models from environment variables
|
|
*/
|
|
private async discoverModels(): Promise<void> {
|
|
console.log('Initializing UncloseAI client...');
|
|
|
|
// Discover chat/code models
|
|
for (let i = 1; i <= 9999; i++) {
|
|
const endpoint = Deno.env.get(`MODEL_ENDPOINT_${i}`);
|
|
if (!endpoint) break;
|
|
|
|
console.log(`Endpoint ${i}: ${endpoint}`);
|
|
await this.discoverModelsFromEndpoint(endpoint);
|
|
}
|
|
|
|
// Discover TTS endpoints
|
|
for (let i = 1; i <= 9999; i++) {
|
|
const endpoint = Deno.env.get(`TTS_ENDPOINT_${i}`);
|
|
if (!endpoint) break;
|
|
this.ttsEndpoints.push(endpoint);
|
|
}
|
|
|
|
console.log(`Discovered ${this.models.length} models, ${this.ttsEndpoints.length} TTS endpoints\n`);
|
|
}
|
|
|
|
/**
|
|
* Discover models from a specific endpoint
|
|
*/
|
|
private async discoverModelsFromEndpoint(endpoint: string): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${endpoint}/models`, {
|
|
signal: AbortSignal.timeout(10000)
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.data) {
|
|
for (const model of data.data) {
|
|
const modelId = model.id;
|
|
|
|
// Skip modelperm-* entries
|
|
if (modelId.startsWith('modelperm-')) continue;
|
|
|
|
const maxTokens = model.max_model_len || 8192;
|
|
this.models.push({ id: modelId, endpoint, maxTokens });
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
// Silently skip failed endpoints
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Non-streaming chat completion
|
|
*/
|
|
async chat(messages: Message[], options: ChatOptions = {}): Promise<string> {
|
|
const modelIdx = options.modelIdx ?? 0;
|
|
const maxTokens = options.maxTokens ?? 100;
|
|
const temperature = options.temperature ?? 0.7;
|
|
|
|
if (modelIdx >= this.models.length) {
|
|
throw new Error('Invalid model index');
|
|
}
|
|
|
|
const model = this.models[modelIdx];
|
|
const url = `${model.endpoint}/chat/completions`;
|
|
|
|
const request = {
|
|
model: model.id,
|
|
messages,
|
|
stream: false,
|
|
max_tokens: maxTokens,
|
|
temperature
|
|
};
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(request),
|
|
signal: AbortSignal.timeout(this.timeout)
|
|
});
|
|
|
|
const data = await response.json();
|
|
return data.choices[0].message.content;
|
|
}
|
|
|
|
/**
|
|
* Streaming chat completion - returns an async iterator
|
|
*/
|
|
async *chatStream(messages: Message[], options: ChatOptions = {}): AsyncGenerator<string> {
|
|
const modelIdx = options.modelIdx ?? 0;
|
|
const maxTokens = options.maxTokens ?? 500;
|
|
const temperature = options.temperature ?? 0.7;
|
|
|
|
if (modelIdx >= this.models.length) {
|
|
throw new Error('Invalid model index');
|
|
}
|
|
|
|
const model = this.models[modelIdx];
|
|
const url = `${model.endpoint}/chat/completions`;
|
|
|
|
const request = {
|
|
model: model.id,
|
|
messages,
|
|
stream: true,
|
|
max_tokens: maxTokens,
|
|
temperature
|
|
};
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(request),
|
|
signal: AbortSignal.timeout(this.timeout)
|
|
});
|
|
|
|
if (!response.body) {
|
|
throw new Error('No response body');
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data: ')) continue;
|
|
|
|
const data = line.slice(6).trim();
|
|
if (data === '[DONE]') return;
|
|
|
|
try {
|
|
const json = JSON.parse(data);
|
|
const content = json.choices?.[0]?.delta?.content;
|
|
if (content) {
|
|
yield content;
|
|
}
|
|
} catch {
|
|
// Skip malformed JSON
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Text-to-speech generation
|
|
*/
|
|
async tts(text: string, voice = 'alloy', outputFile = '/tmp/speech.mp3'): Promise<boolean> {
|
|
if (this.ttsEndpoints.length === 0) return false;
|
|
|
|
const endpoint = this.ttsEndpoints[0];
|
|
const url = `${endpoint}/audio/speech`;
|
|
|
|
const request = {
|
|
model: 'tts-1',
|
|
voice,
|
|
input: text
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(request),
|
|
signal: AbortSignal.timeout(this.timeout)
|
|
});
|
|
|
|
const audioData = await response.arrayBuffer();
|
|
await Deno.writeFile(outputFile, new Uint8Array(audioData));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Demo program showing library usage
|
|
*/
|
|
if (import.meta.main) {
|
|
console.log('=== UncloseAI Deno Client (with Streaming) ===\n');
|
|
|
|
// Initialize client
|
|
const client = new UncloseAI();
|
|
|
|
// Wait for discovery to complete
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
if (client.models.length === 0) {
|
|
console.log('ERROR: No models discovered');
|
|
Deno.exit(1);
|
|
}
|
|
|
|
// Non-streaming chat example
|
|
console.log('=== Non-Streaming Chat ===');
|
|
console.log(`Model: ${client.models[0].id}`);
|
|
|
|
try {
|
|
const messages = [{ role: 'user', content: 'Explain quantum computing in one sentence' }];
|
|
const response = await client.chat(messages);
|
|
console.log(`Response: ${response}\n`);
|
|
} catch (error) {
|
|
console.log(`Error: ${error.message}\n`);
|
|
}
|
|
|
|
// Streaming chat example
|
|
const modelIdx = client.models.length >= 2 ? 1 : 0;
|
|
console.log('=== Streaming Chat ===');
|
|
console.log(`Model: ${client.models[modelIdx].id}`);
|
|
Deno.stdout.writeSync(new TextEncoder().encode('Response: '));
|
|
|
|
try {
|
|
const messages = [{ role: 'user', content: 'Write a hello world program in Deno TypeScript' }];
|
|
for await (const content of client.chatStream(messages, { modelIdx })) {
|
|
Deno.stdout.writeSync(new TextEncoder().encode(content));
|
|
}
|
|
console.log('\n');
|
|
} catch (error) {
|
|
console.log(`\nError: ${error.message}\n`);
|
|
}
|
|
|
|
// TTS example
|
|
if (client.ttsEndpoints.length > 0) {
|
|
console.log('=== TTS Speech Generation ===');
|
|
console.log('Model: tts-1');
|
|
|
|
if (await client.tts('Hello from UncloseAI Deno client!', 'alloy', '/tmp/speech.mp3')) {
|
|
console.log('Audio saved to /tmp/speech.mp3');
|
|
} else {
|
|
console.log('TTS failed');
|
|
}
|
|
}
|
|
|
|
console.log('\n=== Examples Complete ===');
|
|
}
|