pinme-llm について
PinMe Worker から PinMe の OpenRouter プロキシ API を呼び出す TypeScript ガイドです。モデル一覧、チャット補完、ストリーミング、ウェブ検索、エラー処理を説明し、Worker に実際の OpenRouter キーを持たせずに利用できます。
主な機能
- 01利用可能な OpenRouter モデルの一覧取得
- 02チャット補完 API の呼び出し
- 03SSE によるレスポンス転送
- 04OpenRouter ウェブ検索ツールの利用
- 05プロキシと上流のエラー処理
活用シーン
- 01Worker ベースのチャットエンドポイント構築
- 02フロントエンドへのモデル応答のストリーミング
- 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 の例 |
|---|---|---|
| 401 | API キーがない、無効、または project_name と一致しない | "X-API-Key header is required" / "Invalid API key" / "Invalid API key or project name" |
| 400 | project_name がない、または OpenRouter キーが設定されていない | "project_name is required" / "LLM service not configured for this project" |
| 403 | LLM の残高が不足しているか、無効になっている | "Insufficient balance, please recharge to continue using LLM service" |
| 413 | リクエストボディが 1 MB を超えている | "Request body too large (max 1MB)" |
| 500 | 上流へのリクエスト前にプロキシが失敗した | "Failed to build request" |
| 502 | LLM サービスを利用できない | "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);