| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { TOOL_SCHEMAS } from './schemas.js'; |
| import { routeToolCall } from './router.js'; |
| import { buildMessages } from './prompts.js'; |
|
|
| export const ENDPOINT = 'https://router.huggingface.co/v1/chat/completions'; |
|
|
| |
| |
| |
| export const MODELS = [ |
| { id: 'Qwen/Qwen2.5-7B-Instruct:featherless-ai', label: 'Qwen2.5 7B Instruct' }, |
| { id: 'meta-llama/Llama-3.3-70B-Instruct:groq', label: 'Llama 3.3 70B' }, |
| { id: 'Qwen/Qwen2.5-72B-Instruct:novita', label: 'Qwen2.5 72B Instruct' }, |
| { id: 'Qwen/Qwen3-32B:featherless-ai', label: 'Qwen3 32B' }, |
| ]; |
|
|
| export const DEFAULT_MODEL = MODELS[0].id; |
|
|
| |
| export class InferenceError extends Error { |
| constructor(message, { status = null, body = null } = {}) { |
| super(message); |
| this.name = 'InferenceError'; |
| this.status = status; |
| this.body = body; |
| } |
| } |
|
|
| async function callModel({ model, messages, token, signal }) { |
| let res; |
| try { |
| res = await fetch(ENDPOINT, { |
| method: 'POST', |
| headers: { |
| Authorization: `Bearer ${token}`, |
| 'Content-Type': 'application/json', |
| }, |
| body: JSON.stringify({ |
| model, |
| messages, |
| tools: TOOL_SCHEMAS, |
| tool_choice: 'auto', |
| temperature: 0.3, |
| max_tokens: 800, |
| }), |
| signal, |
| }); |
| } catch (err) { |
| if (err.name === 'AbortError') throw err; |
| throw new InferenceError(`Could not reach the inference router: ${err.message}`); |
| } |
|
|
| const text = await res.text(); |
| let data; |
| try { |
| data = JSON.parse(text); |
| } catch { |
| throw new InferenceError(`The provider returned a response that is not JSON (HTTP ${res.status}).`, { |
| status: res.status, body: text.slice(0, 400), |
| }); |
| } |
|
|
| if (!res.ok || data.error) { |
| const detail = typeof data.error === 'string' ? data.error : data.error?.message ?? `HTTP ${res.status}`; |
| const hint = res.status === 401 ? ' Check that the Hugging Face token is valid.' |
| : res.status === 402 ? ' The token has no inference credit left for this month.' |
| : res.status === 429 ? ' The provider is rate-limiting; wait a moment and retry.' |
| : ''; |
| throw new InferenceError(detail + hint, { status: res.status, body: data }); |
| } |
|
|
| const message = data.choices?.[0]?.message; |
| if (!message) { |
| throw new InferenceError('The provider returned no message.', { status: res.status, body: data }); |
| } |
| return message; |
| } |
|
|
| |
| |
| function stripReasoning(content) { |
| if (!content) return content; |
| return content |
| .replace(/<think>[\s\S]*?<\/think>/g, '') |
| .replace(/^[\s\S]*?<\/think>/, '') |
| .trim(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function runTurn({ |
| history = [], |
| userText, |
| context, |
| token, |
| model = DEFAULT_MODEL, |
| maxTurns = 6, |
| onEvent = () => {}, |
| signal, |
| }) { |
| const messages = [...history, { role: 'user', content: userText }]; |
| const trace = []; |
|
|
| |
| |
| context.turnId = (context.turnId ?? 0) + 1; |
|
|
| const emit = (event) => { |
| trace.push(event); |
| onEvent(event); |
| }; |
|
|
| for (let turn = 1; turn <= maxTurns; turn++) { |
| emit({ type: 'model_request', turn, model }); |
|
|
| const message = await callModel({ |
| model, messages: buildMessages(messages), token, signal, |
| }); |
|
|
| const toolCalls = message.tool_calls ?? []; |
|
|
| |
| |
| messages.push({ |
| role: 'assistant', |
| content: message.content ?? '', |
| ...(toolCalls.length ? { tool_calls: toolCalls } : {}), |
| }); |
|
|
| if (toolCalls.length === 0) { |
| const reply = stripReasoning(message.content) || ''; |
| emit({ type: 'final', turn, reply }); |
| return { reply, history: messages, trace }; |
| } |
|
|
| for (const call of toolCalls) { |
| const name = call.function?.name ?? '(unnamed)'; |
| const rawArgs = call.function?.arguments ?? '{}'; |
| emit({ type: 'tool_call', turn, name, arguments: rawArgs }); |
|
|
| const result = routeToolCall(name, rawArgs, context); |
| emit({ type: 'tool_result', turn, name, ok: result.ok !== false, result }); |
|
|
| messages.push({ |
| role: 'tool', |
| tool_call_id: call.id ?? `${name}-${turn}`, |
| name, |
| content: JSON.stringify(result), |
| }); |
| } |
| } |
|
|
| |
| |
| const reply = 'I could not finish that request — too many tool steps without reaching an answer. Could you narrow it down?'; |
| emit({ type: 'exhausted', turn: maxTurns, reply }); |
| return { reply, history: messages, trace }; |
| } |
|
|