mcpField
P
pinme-llm

pinme-llm

github.com/glitternetwork開発3.8k500 回インストール16 週間前に更新
リポジトリ

pinme-llm について

PinMe Worker から PinMe の OpenRouter プロキシ API を呼び出す TypeScript ガイドです。モデル一覧、チャット補完、ストリーミング、ウェブ検索、エラー処理を説明し、Worker に実際の OpenRouter キーを持たせずに利用できます。

主な機能

  1. 01利用可能な OpenRouter モデルの一覧取得
  2. 02チャット補完 API の呼び出し
  3. 03SSE によるレスポンス転送
  4. 04OpenRouter ウェブ検索ツールの利用
  5. 05プロキシと上流のエラー処理

活用シーン

  1. 01Worker ベースのチャットエンドポイント構築
  2. 02フロントエンドへのモデル応答のストリーミング
  3. 03チャットフローへのモデル駆動ウェブ検索の追加

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/glitternetwork/pinme --skill pinme-llm

インストール後、Claude Code で/pinme-llmを入力して読み込まれたか確認します。

詳細

PinMe Worker OpenRouter API 統合

PinMe Worker(TypeScript)で PinMe プラットフォームの OpenRouter プロキシ API を呼び出す方法を説明します。Worker は PinMe プロジェクトの API キーを使用し、実際の OpenRouter API キーを保持することはありません。

環境変数

以下の環境変数は Worker の作成時に自動的に注入されるため、手動で設定する必要はありません。

// backend/src/worker.ts
export interface Env {
  DB: D1Database;
  API_KEY: string;       // Project API Key from create_worker
  PROJECT_NAME: string;  // Actual project_name from create_worker; must match API_KEY
  BASE_URL?: string;     // Optional override for PinMe API base URL, defaults to https://pinme.cloud
}

API_KEY は Worker の PinMe に対する認証に使用されます。chat/completions には PROJECT_NAME が必要であり、API_KEY と同じプロジェクトに属している必要があります。BASE_URL が設定されていない場合は、https://pinme.cloud を使用してください。


Models API

エンドポイント: GET {BASE_URL}/api/v1/models 認証: X-API-Key ヘッダー(env.API_KEY を使用) リクエストボディ: なし

Worker で利用可能な OpenRouter モデルを一覧表示する場合に使用します。レスポンスボディ、ステータス、ヘッダーは OpenRouter /models からそのまま渡されます。

async function listModels(env: Env): Promise<unknown> {
  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';
  const resp = await fetch(`${baseUrl}/api/v1/models`, {
    headers: { 'X-API-Key': env.API_KEY },
  });

  if (!resp.ok) {
    throw new Error(await extractPinmeOpenRouterError(resp));
  }

  return await resp.json();
}

Chat Completions API

エンドポイント: POST {BASE_URL}/api/v1/chat/completions?project_name={project_name} 認証: X-API-Key ヘッダー(env.API_KEY を使用) リクエストボディ: OpenRouter の chat/completions 形式。1 MB のサイズチェック後、そのまま渡されます ストリーミング: SSE(stream: true)に対応 ウェブ検索: tools 配列を介した OpenRouter の openrouter:web_search サーバーツールに対応

リクエスト形式
{
  "model": "openai/gpt-4o-mini",
  "messages": [
    { "role": "system", "content": "You are a helpful assistant." },
    { "role": "user", "content": "Hello!" }
  ],
  "stream": true
}

create_worker の env.PROJECT_NAME を使用し、クエリ文字列では必ず URL エンコードしてください。利用可能なモデルについては、GET /api/v1/models を呼び出すか、OpenRouter のモデル ID を参照してください。

OpenRouter ウェブ検索

PinMe は検索専用エンドポイントを提供していません。ウェブを検索するには、OpenRouter の openrouter:web_search サーバーツールを chat/completions に渡してください。検索するかどうか、いつ検索するかはモデルが判断します。

検索量とコストを一定の範囲に収めるため、必ず max_results と max_total_results を設定してください。

async function searchWithLLM(env: Env, query: string): Promise<string> {
  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';
  const resp = await fetch(
    `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': env.API_KEY,
      },
      body: JSON.stringify({
        model: 'openai/gpt-5.2',
        messages: [{ role: 'user', content: query }],
        tools: [
          {
            type: 'openrouter:web_search',
            parameters: {
              engine: 'auto',
              max_results: 5,
              max_total_results: 10,
            },
          },
        ],
      }),
    },
  );

  if (!resp.ok) {
    throw new Error(await extractPinmeOpenRouterError(resp));
  }

  const data = await resp.json() as { choices: Array<{ message?: { content?: string } }> };
  return data.choices[0]?.message?.content ?? '';
}
レスポンス形式

成功したリクエストでは、OpenRouter の生のレスポンスボディが返されます。

ストリーミングなしの成功(200):

{
  "id": "chatcmpl-...",
  "choices": [{ "message": { "role": "assistant", "content": "Hello!" }, "finish_reason": "stop" }],
  "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
}

ストリーミングの成功(200): SSE 形式

data: {"choices":[{"delta":{"content":"Hello"}}]}
data: {"choices":[{"delta":{"content":" there"}}]}
data: [DONE]

エラー:

HTTP ステータス意味data.error の例
401API キーがない、無効、または project_name と一致しない"X-API-Key header is required" / "Invalid API key" / "Invalid API key or project name"
400project_name がない、または OpenRouter キーが設定されていない"project_name is required" / "LLM service not configured for this project"
403LLM の残高が不足しているか、無効になっている"Insufficient balance, please recharge to continue using LLM service"
413リクエストボディが 1 MB を超えている"Request body too large (max 1MB)"
500上流へのリクエスト前にプロキシが失敗した"Failed to build request"
502LLM サービスを利用できない"LLM service unavailable"

OpenRouter がリクエストを受け取り 4xx/5xx を返した場合、PinMe はラップせず、OpenRouter のステータス、ヘッダー、レスポンスボディをそのまま渡します。

Worker のコード例 — ストリーミングなし
async function callLLM(
  env: Env,
  messages: Array<{ role: string; content: string }>,
  model = 'openai/gpt-4o-mini',
): Promise<{ content: string; error?: string }> {
  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';
  const resp = await fetch(
    `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': env.API_KEY,
      },
      body: JSON.stringify({ model, messages }),
    },
  );

  if (!resp.ok) {
    return { content: '', error: await extractPinmeOpenRouterError(resp) };
  }

  const data = await resp.json() as { choices: Array<{ message: { content: string } }> };
  return { content: data.choices[0]?.message?.content || '' };
}

// Usage in routes
async function handleChat(request: Request, env: Env): Promise<Response> {
  const { question } = await request.json() as { question: string };

  const result = await callLLM(env, [
    { role: 'system', content: 'You are a helpful assistant.' },
    { role: 'user', content: question },
  ]);

  if (result.error) {
    return json({ error: result.error }, 502);
  }
  return json({ answer: result.content });
}
Worker のコード例 — ストリーミング(SSE パススルー)
async function handleChatStream(request: Request, env: Env): Promise<Response> {
  const body = await request.text();
  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';

  // Ensure stream=true in the request
  let parsed = JSON.parse(body);
  parsed.stream = true;

  const resp = await fetch(
    `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': env.API_KEY,
      },
      body: JSON.stringify(parsed),
    },
  );

  if (!resp.ok) {
    return json({ error: await extractPinmeOpenRouterError(resp) }, resp.status);
  }

  // Pass through SSE stream directly
  return new Response(resp.body, {
    status: 200,
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      ...CORS_HEADERS,
    },
  });
}
フロントエンドの SSE ストリーム受信例
async function streamChat(question: string, onChunk: (text: string) => void): Promise<void> {
  const resp = await fetch(getApiUrl('/api/chat/stream'), {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ question }),
  });

  const reader = resp.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  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()!; // Keep incomplete line

    for (const line of lines) {
      if (!line.startsWith('data: ')) continue;
      const payload = line.slice(6);
      if (payload === '[DONE]') return;

      const chunk = JSON.parse(payload) as { choices: Array<{ delta: { content?: string } }> };
      const content = chunk.choices[0]?.delta?.content;
      if (content) onChunk(content);
    }
  }
}

エラー処理パターン

/api/v1/models と /api/v1/chat/completions では、成功時に OpenRouter の生のレスポンスが返されます。OpenRouter へのリクエスト前にプロキシでエラーが発生した場合は、PinMe のラップされたエラー形式が使用されます。

interface PinmeResponse<T = unknown> {
  code: number;   // 200=success, other=failure
  msg: string;    // "ok" | "error" | "invalid params"
  data?: T;       // Business data on success, may contain { error: string } on failure
}
推奨エラー抽出関数
async function extractPinmeOpenRouterError(resp: Response): Promise<string> {
  const fallback = `HTTP ${resp.status}`;
  try {
    const body = await resp.clone().json() as PinmeResponse | { error?: { message?: string } } | { error?: string };
    if ('data' in body && body.data && typeof body.data === 'object' && 'error' in body.data) {
      return String((body.data as { error: unknown }).error);
    }
    if ('msg' in body && typeof body.msg === 'string' && body.msg) {
      return body.msg;
    }
    if ('error' in body) {
      const error = body.error;
      if (typeof error === 'string') return error;
      if (error && typeof error === 'object' && 'message' in error) {
        return String((error as { message: unknown }).message);
      }
    }
  } catch {
    try {
      const text = await resp.text();
      if (text) return text;
    } catch {
      // Ignore and return fallback below.
    }
  }
  return fallback;
}
オプションの JSON ヘルパー

ストリーミングなしの POST 呼び出しには、このヘルパーを使用してください。成功時には OpenRouter の生の JSON を返します。

async function callOpenRouterJSON<T>(url: string, apiKey: string, body: unknown): Promise<{ data?: T; error?: string }> {
  let resp: Response;
  try {
    resp = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
      body: JSON.stringify(body),
    });
  } catch {
    return { error: 'Network error' };
  }

  if (!resp.ok) {
    return { error: await extractPinmeOpenRouterError(resp) };
  }

  return { data: await resp.json() as T };
}
使用例
const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';

// Call LLM (non-streaming)
const llmResult = await callOpenRouterJSON<{ choices: Array<{ message: { content: string } }> }>(
  `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`, env.API_KEY,
  { model: 'openai/gpt-4o-mini', messages: [{ role: 'user', content: 'Hi' }] },
);
if (llmResult.error) return json({ error: llmResult.error }, 502);

コメント