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.

5 phút đọc
Tập này đang được chuẩn bị, quay lại sau nhé.

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