import { IAskClientOptions, IAskConfig, ICompletionRequest, ICompletionResponse, IErrorResponse, IMessage, } from './types'; /** * Browser-compatible client for the Ask API * * @example * ```typescript * const client = new AskClient({ * baseUrl: 'https://your-worker.workers.dev/v1', * askApiKey: 'ask_abc123...', * defaultProvider: 'openai', * defaultModel: 'gpt-4' * }); * * // Non-streaming request * const response = await client.completion({ * messages: [{ role: 'user', content: 'Hello!' }] * }); * console.log(response.content); * * // Streaming request * const stream = await client.streamCompletion({ * messages: [{ role: 'user', content: 'Tell me a story' }] * }); * * for await (const chunk of stream) { * process.stdout.write(chunk); * } * ``` */ export class AskClient { private options: Required; constructor(options: IAskClientOptions) { this.options = { baseUrl: options.baseUrl, askApiKey: options.askApiKey || '', defaultProvider: options.defaultProvider || 'openai', defaultModel: options.defaultModel || 'gpt-4', providerKeys: options.providerKeys || {}, cloudflareAccountId: options.cloudflareAccountId || '', headers: options.headers || {}, debug: options.debug || false, }; // Validate base URL if (!this.options.baseUrl) { throw new Error('AskClient: baseUrl is required'); } } /** * Execute a completion request (non-streaming) * * @param params - Request parameters * @returns Promise with completion response */ async completion(params: { messages: IMessage[]; systemPrompt?: string; config?: Partial; }): Promise { const url = `${this.options.baseUrl}/completion`; const payload: ICompletionRequest = { messages: params.messages, systemPrompt: params.systemPrompt, config: { provider: params.config?.provider || this.options.defaultProvider, model: params.config?.model || this.options.defaultModel, temperature: params.config?.temperature ?? 0.7, maxTokens: params.config?.maxTokens, topP: params.config?.topP, frequencyPenalty: params.config?.frequencyPenalty, presencePenalty: params.config?.presencePenalty, stream: false, user: params.config?.user, }, }; this.log('Completion request to', url, payload.config); const response = await fetch(url, { method: 'POST', headers: this.buildHeaders(params.config?.provider), body: JSON.stringify(payload), }); if (!response.ok) { const error: IErrorResponse = await response.json().catch(async () => ({ error: await response.text(), })); throw new Error(`Ask API error (${response.status}): ${error.error}`); } const data = await response.json(); return { content: data.content || '', provider: data.provider, model: data.model, usage: data.usage, }; } /** * Execute a streaming completion request * * @param params - Request parameters * @returns Async iterable of text chunks */ async streamCompletion(params: { messages: IMessage[]; systemPrompt?: string; config?: Partial; }): Promise> { const url = `${this.options.baseUrl}/completion`; const payload: ICompletionRequest = { messages: params.messages, systemPrompt: params.systemPrompt, config: { provider: params.config?.provider || this.options.defaultProvider, model: params.config?.model || this.options.defaultModel, temperature: params.config?.temperature ?? 0.7, maxTokens: params.config?.maxTokens, topP: params.config?.topP, frequencyPenalty: params.config?.frequencyPenalty, presencePenalty: params.config?.presencePenalty, stream: true, user: params.config?.user, }, }; this.log('Streaming request to', url, payload.config); const response = await fetch(url, { method: 'POST', headers: this.buildHeaders(params.config?.provider), body: JSON.stringify(payload), }); if (!response.ok) { const error: IErrorResponse = await response.json().catch(async () => ({ error: await response.text(), })); throw new Error(`Ask API error (${response.status}): ${error.error}`); } if (!response.body) { throw new Error('Response body is null'); } return this.parseStreamResponse(response.body); } /** * Get a raw ReadableStream from a streaming completion * Useful for piping to other streams or custom processing * * @param params - Request parameters * @returns Promise with ReadableStream */ async getStream(params: { messages: IMessage[]; systemPrompt?: string; config?: Partial; }): Promise> { const url = `${this.options.baseUrl}/completion`; const payload: ICompletionRequest = { messages: params.messages, systemPrompt: params.systemPrompt, config: { provider: params.config?.provider || this.options.defaultProvider, model: params.config?.model || this.options.defaultModel, temperature: params.config?.temperature ?? 0.7, maxTokens: params.config?.maxTokens, topP: params.config?.topP, frequencyPenalty: params.config?.frequencyPenalty, presencePenalty: params.config?.presencePenalty, stream: true, user: params.config?.user, }, }; this.log('Stream request to', url, payload.config); const response = await fetch(url, { method: 'POST', headers: this.buildHeaders(params.config?.provider), body: JSON.stringify(payload), }); if (!response.ok) { const error: IErrorResponse = await response.json().catch(async () => ({ error: await response.text(), })); throw new Error(`Ask API error (${response.status}): ${error.error}`); } if (!response.body) { throw new Error('Response body is null'); } return response.body; } /** * List available models * * @returns Promise with models list */ async listModels(): Promise { const url = `${this.options.baseUrl}/models`; this.log('List models from', url); const response = await fetch(url, { method: 'GET', headers: this.buildHeaders(), }); if (!response.ok) { const error: IErrorResponse = await response.json().catch(async () => ({ error: await response.text(), })); throw new Error(`Ask API error (${response.status}): ${error.error}`); } return await response.json(); } /** * Health check * * @returns Promise with health status */ async health(): Promise { const url = `${this.options.baseUrl}/health`; this.log('Health check', url); const response = await fetch(url, { method: 'GET', headers: this.buildHeaders(), }); if (!response.ok) { const error: IErrorResponse = await response.json().catch(async () => ({ error: await response.text(), })); throw new Error(`Ask API error (${response.status}): ${error.error}`); } return await response.json(); } /** * Build request headers based on authentication method */ private buildHeaders(provider?: string): Record { const headers: Record = { 'Content-Type': 'application/json', ...this.options.headers, }; // If Ask API key is present, use it (takes precedence) if (this.options.askApiKey) { headers['Authorization'] = `Bearer ${this.options.askApiKey}`; this.log('Using Ask API key for authentication'); return headers; } // Fallback: Use provider-specific keys const keys = this.options.providerKeys; if (provider === 'openai' && keys.openai) { headers['x-openai-api-key'] = keys.openai; } else if (provider === 'anthropic' && keys.anthropic) { headers['x-claude-api-key'] = keys.anthropic; } else if (provider === 'groq' && keys.groq) { headers['x-groq-api-key'] = keys.groq; } else if (provider === 'google' && keys.google) { headers['x-google-ai-api-key'] = keys.google; } else if (provider === 'mistral' && keys.mistral) { headers['x-mistral-api-key'] = keys.mistral; } else if (provider === 'openrouter' && keys.openrouter) { headers['x-openrouter-api-key'] = keys.openrouter; } else if (provider === 'cohere' && keys.cohere) { headers['x-cohere-api-key'] = keys.cohere; } else if (provider === 'xai' && keys.xai) { headers['x-xai-api-key'] = keys.xai; } else if (provider === 'deepseek' && keys.deepseek) { headers['x-deepseek-api-key'] = keys.deepseek; } else if (provider === 'ai21' && keys.ai21) { headers['x-ai21-api-key'] = keys.ai21; } else if (provider === 'cloudflare') { if (keys.cloudflare) { headers['x-cloudflare-api-key'] = keys.cloudflare; } if (this.options.cloudflareAccountId) { headers['x-cloudflare-account-id'] = this.options.cloudflareAccountId; } } return headers; } /** * Parse streaming response (handles SSE or plain text streams) */ private async *parseStreamResponse(stream: ReadableStream): AsyncIterable { const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ''; try { while (true) { const { value, done } = await reader.read(); if (done) { // Yield any remaining buffer content if (buffer.trim()) { yield buffer; } break; } const chunk = decoder.decode(value, { stream: true }); buffer += chunk; // Check if this is SSE format (data: ...) if (buffer.includes('data:')) { const lines = buffer.split('\n'); buffer = lines.pop() || ''; // Keep incomplete line in buffer for (const line of lines) { const trimmed = line.trim(); // SSE format if (trimmed.startsWith('data:')) { const data = trimmed.slice(5).trim(); // Skip empty data or [DONE] marker if (!data || data === '[DONE]') { continue; } try { // Try to parse as JSON (OpenAI-style streaming) const json = JSON.parse(data); // Check for error in the JSON if (json.error) { throw new Error(typeof json.error === 'string' ? json.error : json.error.message || JSON.stringify(json.error)); } const content = json.choices?.[0]?.delta?.content || json.delta?.content || json.delta || // Direct delta string (Ask API format) json.content; if (content) { yield content; } } catch (e: any) { // If it's an error we threw, propagate it if (e.message && e.message !== 'Unexpected end of JSON input') { throw e; } // Not JSON, yield as plain text yield data; } } } } else { // Plain text streaming (no SSE format) // Yield the chunk immediately yield chunk; buffer = ''; } } } finally { reader.releaseLock(); } } /** * Log debug messages if debug mode is enabled */ private log(...args: any[]): void { if (this.options.debug) { console.log('[AskClient]', ...args); } } }