flare-agent Bài 3: LLM Router — Gọi Groq, Workers AI, Ollama qua 1 interface

Xây dựng @flare-agent/providers — LLM Router chuẩn hóa Groq, Cloudflare Workers AI và Ollama về cùng 1 interface, swap provider không đổi 1 dòng code.

Nghe bài viết này dưới dạng podcast

flare-agent Bài 3: LLM Router — Groq, Workers AI, Ollama

Series: Build Your Own AI Agent Framework trên Cloudflare Bài: 3 / 7 — @flare-agent/providers


Vấn đề cần giải quyết

Mỗi LLM provider có API khác nhau:

  • Groq — OpenAI-compatible REST API
  • Workers AIenv.AI.run() binding
  • Ollama — REST API tương tự OpenAI nhưng response format khác một chút

Nếu code trực tiếp vào từng provider, khi muốn switch từ Groq sang Workers AI phải sửa code ở nhiều nơi. @flare-agent/providers giải quyết điều này bằng cách normalize tất cả về cùng 1 interface.


Cấu trúc package

packages/providers/
  src/
    base.ts           # Abstract BaseProvider
    groq.ts           # GroqProvider
    workersai.ts      # WorkersAIProvider
    ollama.ts         # OllamaProvider
    router.ts         # LLMRouter — factory + dispatch
    index.ts

Base Provider

// src/base.ts
import type { LLMProvider, Message, LLMResponse, ToolSchema } from '@flare-agent/types';

export abstract class BaseProvider implements LLMProvider {
  abstract chat(
    messages: Message[],
    tools?: ToolSchema[]
  ): Promise<LLMResponse>;

  // Default stream implementation — subclass override nếu có native streaming
  stream(
    messages: Message[],
    tools?: ToolSchema[]
  ): ReadableStream<string> {
    return new ReadableStream({
      start: async (controller) => {
        const response = await this.chat(messages, tools);
        if (response.content) {
          controller.enqueue(response.content);
        }
        controller.close();
      },
    });
  }

  // Helper: normalize tool_calls từ OpenAI format sang LLMResponse
  protected normalizeToolCall(toolCall: any): LLMResponse {
    return {
      type: 'tool_call',
      toolCall: {
        id: toolCall.id ?? crypto.randomUUID(),
        name: toolCall.function?.name ?? toolCall.name,
        args: typeof toolCall.function?.arguments === 'string'
          ? JSON.parse(toolCall.function.arguments)
          : toolCall.function?.arguments ?? toolCall.arguments,
      },
    };
  }
}

Groq Provider

// src/groq.ts
import type { Message, LLMResponse, ToolSchema } from '@flare-agent/types';
import { BaseProvider } from './base';

interface GroqConfig {
  apiKey: string;
  model: string;
  baseUrl?: string;
}

export class GroqProvider extends BaseProvider {
  private baseUrl: string;

  constructor(private config: GroqConfig) {
    super();
    this.baseUrl = config.baseUrl ?? 'https://api.groq.com/openai/v1';
  }

  async chat(
    messages: Message[],
    tools?: ToolSchema[]
  ): Promise<LLMResponse> {
    const res = await fetch(`${this.baseUrl}/chat/completions`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.config.apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: this.config.model,
        messages,
        ...(tools?.length && {
          tools: tools.map((t) => ({ type: 'function', function: t })),
          tool_choice: 'auto',
        }),
      }),
    });

    if (!res.ok) {
      throw new Error(`Groq API error: ${res.status} ${await res.text()}`);
    }

    const data = await res.json<any>();
    const msg = data.choices[0].message;

    if (msg.tool_calls?.[0]) {
      return this.normalizeToolCall(msg.tool_calls[0]);
    }

    return { type: 'text', content: msg.content };
  }

  override stream(
    messages: Message[],
    tools?: ToolSchema[]
  ): ReadableStream<string> {
    const config = this.config;
    const baseUrl = this.baseUrl;

    return new ReadableStream({
      async start(controller) {
        const res = await fetch(`${baseUrl}/chat/completions`, {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${config.apiKey}`,
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            model: config.model,
            messages,
            stream: true,
          }),
        });

        const reader = res.body!.getReader();
        const decoder = new TextDecoder();

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          const lines = decoder.decode(value).split('\n');
          for (const line of lines) {
            if (!line.startsWith('data: ')) continue;
            const json = line.slice(6).trim();
            if (json === '[DONE]') { controller.close(); return; }
            try {
              const delta = JSON.parse(json).choices[0].delta.content;
              if (delta) controller.enqueue(delta);
            } catch { /* skip malformed chunks */ }
          }
        }
      },
    });
  }
}

Workers AI Provider

// src/workersai.ts
import type { Message, LLMResponse, ToolSchema } from '@flare-agent/types';
import { BaseProvider } from './base';

interface WorkersAIConfig {
  ai: Ai; // Cloudflare AI binding
  model: string;
}

export class WorkersAIProvider extends BaseProvider {
  constructor(private config: WorkersAIConfig) {
    super();
  }

  async chat(
    messages: Message[],
    tools?: ToolSchema[]
  ): Promise<LLMResponse> {
    const response = await this.config.ai.run(
      this.config.model as any,
      {
        messages,
        ...(tools?.length && {
          tools: tools.map((t) => ({ type: 'function', function: t })),
        }),
      }
    ) as any;

    // Workers AI response format khác OpenAI một chút
    if (response.tool_calls?.[0]) {
      return this.normalizeToolCall(response.tool_calls[0]);
    }

    return { type: 'text', content: response.response };
  }

  override stream(
    messages: Message[]
  ): ReadableStream<string> {
    return this.config.ai.run(
      this.config.model as any,
      { messages, stream: true }
    ) as ReadableStream<string>;
  }
}

Ollama Provider

// src/ollama.ts
import type { Message, LLMResponse, ToolSchema } from '@flare-agent/types';
import { BaseProvider } from './base';

interface OllamaConfig {
  baseUrl: string; // e.g. http://localhost:11434
  model: string;
}

export class OllamaProvider extends BaseProvider {
  constructor(private config: OllamaConfig) {
    super();
  }

  async chat(
    messages: Message[],
    tools?: ToolSchema[]
  ): Promise<LLMResponse> {
    const res = await fetch(`${this.config.baseUrl}/api/chat`, {
      method: 'POST',
      body: JSON.stringify({
        model: this.config.model,
        messages,
        stream: false,
        ...(tools?.length && {
          tools: tools.map((t) => ({ type: 'function', function: t })),
        }),
      }),
    });

    const data = await res.json<any>();
    const msg = data.message;

    if (msg.tool_calls?.[0]) {
      return this.normalizeToolCall(msg.tool_calls[0]);
    }

    return { type: 'text', content: msg.content };
  }
}

LLM Router — factory + dispatch

// src/router.ts
import type { LLMProvider, ProviderName } from '@flare-agent/types';
import { GroqProvider } from './groq';
import { WorkersAIProvider } from './workersai';
import { OllamaProvider } from './ollama';

type ProviderConfig = {
  groq: { apiKey: string; model: string };
  workersai: { ai: Ai; model: string };
  ollama: { baseUrl: string; model: string };
};

export class LLMRouter {
  private providers = new Map<string, LLMProvider>();

  register<T extends ProviderName>(
    name: T,
    config: ProviderConfig[T]
  ): this {
    switch (name) {
      case 'groq':
        this.providers.set(name, new GroqProvider(config as any));
        break;
      case 'workersai':
        this.providers.set(name, new WorkersAIProvider(config as any));
        break;
      case 'ollama':
        this.providers.set(name, new OllamaProvider(config as any));
        break;
    }
    return this;
  }

  get(name: ProviderName): LLMProvider {
    const provider = this.providers.get(name);
    if (!provider) throw new Error(`Provider "${name}" not registered`);
    return provider;
  }
}

Sử dụng

const router = new LLMRouter()
  .register('groq', { apiKey: env.GROQ_API_KEY, model: 'llama-3.3-70b-versatile' })
  .register('workersai', { ai: env.AI, model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast' })
  .register('ollama', { baseUrl: env.OLLAMA_URL, model: 'qwen3:8b' });

// Gọi Groq
const response = await router.get('groq').chat(messages, tools);

// Swap sang Workers AI — không đổi gì khác
const response = await router.get('workersai').chat(messages, tools);

Checklist

  • Tạo packages/providers/
  • Implement đủ 3 providers
  • Unit test: mock fetch, verify response normalization
  • Build thành công

Bài tiếp theo: Bài 4 — Memory: KV, D1 và Vectorize adapters

Chưa có bình luận

Để lại bình luận

Bình luận sẽ được phê duyệt trước khi hiển thị.

Nhận bài viết mới

Mình sẽ gửi email khi có bài mới. Không spam.