{"id":"pinme-llm","name":"pinme-llm","summary":"このスキルは、PinMeプロジェクト(Worker TypeScript)がOpenRouter支援のLLM APIを呼び出す必要がある場合、モデル、チャット/完了、ストリーミング、OpenRouterのウェブ検索などを活用してください。","body":"# PinMe Worker OpenRouter API Integration\n\nGuides how to call PinMe platform's OpenRouter proxy APIs in a PinMe Worker (TypeScript). Workers use the PinMe project API key; they never hold the real OpenRouter API key.\n\n## Environment Variables\n\nThe following environment variables are automatically injected when the Worker is created — no manual configuration needed:\n\n```typescript\n// backend/src/worker.ts\nexport interface Env {\n  DB: D1Database;\n  API_KEY: string;       // Project API Key from create_worker\n  PROJECT_NAME: string;  // Actual project_name from create_worker; must match API_KEY\n  BASE_URL?: string;     // Optional override for PinMe API base URL, defaults to https://pinme.cloud\n}\n```\n\n> `API_KEY` authenticates the Worker to PinMe. `PROJECT_NAME` is required for `chat/completions` and must belong to the same project as `API_KEY`. When `BASE_URL` is not set, use `https://pinme.cloud`.\n\n---\n\n## Models API\n\n**Endpoint:** `GET {BASE_URL}/api/v1/models`\n**Authentication:** `X-API-Key` header (using `env.API_KEY`)\n**Request Body:** none\n\nUse this when the Worker needs to list available OpenRouter models. The response body, status, and headers are passed through from OpenRouter `/models`.\n\n```typescript\nasync function listModels(env: Env): Promise<unknown> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(`${baseUrl}/api/v1/models`, {\n    headers: { 'X-API-Key': env.API_KEY },\n  });\n\n  if (!resp.ok) {\n    throw new Error(await extractPinmeOpenRouterError(resp));\n  }\n\n  return await resp.json();\n}\n```\n\n---\n\n## Chat Completions API\n\n**Endpoint:** `POST {BASE_URL}/api/v1/chat/completions?project_name={project_name}`\n**Authentication:** `X-API-Key` header (using `env.API_KEY`)\n**Request Body:** OpenRouter chat/completions format, passed through as-is after a 1MB size check\n**Streaming:** Supports SSE (`stream: true`)\n**Web Search:** Supports OpenRouter `openrouter:web_search` server tool via the `tools` array\n\n### Request Format\n\n```json\n{\n  \"model\": \"openai/gpt-4o-mini\",\n  \"messages\": [\n    { \"role\": \"system\", \"content\": \"You are a helpful assistant.\" },\n    { \"role\": \"user\", \"content\": \"Hello!\" }\n  ],\n  \"stream\": true\n}\n```\n\n> Use `env.PROJECT_NAME` from `create_worker`; always URL-encode it in the query string. For available models, call `GET /api/v1/models` or refer to OpenRouter model IDs.\n\n### OpenRouter Web Search\n\nPinMe does not provide a raw search endpoint. To search the web, pass OpenRouter's `openrouter:web_search` server tool to `chat/completions`; the model decides whether and when to search.\n\nAlways set `max_results` and `max_total_results` to keep search volume and cost bounded.\n\n```typescript\nasync function searchWithLLM(env: Env, query: string): Promise<string> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'X-API-Key': env.API_KEY,\n      },\n      body: JSON.stringify({\n        model: 'openai/gpt-5.2',\n        messages: [{ role: 'user', content: query }],\n        tools: [\n          {\n            type: 'openrouter:web_search',\n            parameters: {\n              engine: 'auto',\n              max_results: 5,\n              max_total_results: 10,\n            },\n          },\n        ],\n      }),\n    },\n  );\n\n  if (!resp.ok) {\n    throw new Error(await extractPinmeOpenRouterError(resp));\n  }\n\n  const data = await resp.json() as { choices: Array<{ message?: { content?: string } }> };\n  return data.choices[0]?.message?.content ?? '';\n}\n```\n\n### Response Format\n\nSuccessful requests return OpenRouter's raw response body.\n\n**Non-streaming Success (200):**\n```json\n{\n  \"id\": \"chatcmpl-...\",\n  \"choices\": [{ \"message\": { \"role\": \"assistant\", \"content\": \"Hello!\" }, \"finish_reason\": \"stop\" }],\n  \"usage\": { \"prompt_tokens\": 10, \"completion_tokens\": 5, \"total_tokens\": 15 }\n}\n```\n\n**Streaming Success (200):** SSE format\n```\ndata: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\ndata: {\"choices\":[{\"delta\":{\"content\":\" there\"}}]}\ndata: [DONE]\n```\n\n**Errors:**\n\n| HTTP Status | Meaning | data.error Example |\n|-------------|---------|-------------------|\n| 401 | API Key missing, invalid, or mismatched with project_name | `\"X-API-Key header is required\"` / `\"Invalid API key\"` / `\"Invalid API key or project name\"` |\n| 400 | project_name missing or OpenRouter key not configured | `\"project_name is required\"` / `\"LLM service not configured for this project\"` |\n| 403 | LLM balance insufficient or disabled | `\"Insufficient balance, please recharge to continue using LLM service\"` |\n| 413 | Request body exceeds 1MB | `\"Request body too large (max 1MB)\"` |\n| 500 | Proxy failed before upstream request | `\"Failed to build request\"` |\n| 502 | LLM service unavailable | `\"LLM service unavailable\"` |\n\nIf OpenRouter receives the request and returns a 4xx/5xx, PinMe passes through OpenRouter's status, headers, and response body instead of wrapping it.\n\n### Worker Example Code — Non-streaming\n\n```typescript\nasync function callLLM(\n  env: Env,\n  messages: Array<{ role: string; content: string }>,\n  model = 'openai/gpt-4o-mini',\n): Promise<{ content: string; error?: string }> {\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'X-API-Key': env.API_KEY,\n      },\n      body: JSON.stringify({ model, messages }),\n    },\n  );\n\n  if (!resp.ok) {\n    return { content: '', error: await extractPinmeOpenRouterError(resp) };\n  }\n\n  const data = await resp.json() as { choices: Array<{ message: { content: string } }> };\n  return { content: data.choices[0]?.message?.content || '' };\n}\n\n// Usage in routes\nasync function handleChat(request: Request, env: Env): Promise<Response> {\n  const { question } = await request.json() as { question: string };\n\n  const result = await callLLM(env, [\n    { role: 'system', content: 'You are a helpful assistant.' },\n    { role: 'user', content: question },\n  ]);\n\n  if (result.error) {\n    return json({ error: result.error }, 502);\n  }\n  return json({ answer: result.content });\n}\n```\n\n### Worker Example Code — Streaming (SSE Passthrough)\n\n```typescript\nasync function handleChatStream(request: Request, env: Env): Promise<Response> {\n  const body = await request.text();\n  const baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n\n  // Ensure stream=true in the request\n  let parsed = JSON.parse(body);\n  parsed.stream = true;\n\n  const resp = await fetch(\n    `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'X-API-Key': env.API_KEY,\n      },\n      body: JSON.stringify(parsed),\n    },\n  );\n\n  if (!resp.ok) {\n    return json({ error: await extractPinmeOpenRouterError(resp) }, resp.status);\n  }\n\n  // Pass through SSE stream directly\n  return new Response(resp.body, {\n    status: 200,\n    headers: {\n      'Content-Type': 'text/event-stream',\n      'Cache-Control': 'no-cache',\n      'Connection': 'keep-alive',\n      ...CORS_HEADERS,\n    },\n  });\n}\n```\n\n### Frontend SSE Stream Consumer Example\n\n```typescript\nasync function streamChat(question: string, onChunk: (text: string) => void): Promise<void> {\n  const resp = await fetch(getApiUrl('/api/chat/stream'), {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ question }),\n  });\n\n  const reader = resp.body!.getReader();\n  const decoder = new TextDecoder();\n  let buffer = '';\n\n  while (true) {\n    const { done, value } = await reader.read();\n    if (done) break;\n\n    buffer += decoder.decode(value, { stream: true });\n    const lines = buffer.split('\\n');\n    buffer = lines.pop()!; // Keep incomplete line\n\n    for (const line of lines) {\n      if (!line.startsWith('data: ')) continue;\n      const payload = line.slice(6);\n      if (payload === '[DONE]') return;\n\n      const chunk = JSON.parse(payload) as { choices: Array<{ delta: { content?: string } }> };\n      const content = chunk.choices[0]?.delta?.content;\n      if (content) onChunk(content);\n    }\n  }\n}\n```\n\n---\n\n## Error Handling Pattern\n\nFor `/api/v1/models` and `/api/v1/chat/completions`, successful responses are raw OpenRouter responses. Proxy failures before the OpenRouter request use PinMe's wrapped error format:\n\n```typescript\ninterface PinmeResponse<T = unknown> {\n  code: number;   // 200=success, other=failure\n  msg: string;    // \"ok\" | \"error\" | \"invalid params\"\n  data?: T;       // Business data on success, may contain { error: string } on failure\n}\n```\n\n### Recommended Error Extractor\n\n```typescript\nasync function extractPinmeOpenRouterError(resp: Response): Promise<string> {\n  const fallback = `HTTP ${resp.status}`;\n  try {\n    const body = await resp.clone().json() as PinmeResponse | { error?: { message?: string } } | { error?: string };\n    if ('data' in body && body.data && typeof body.data === 'object' && 'error' in body.data) {\n      return String((body.data as { error: unknown }).error);\n    }\n    if ('msg' in body && typeof body.msg === 'string' && body.msg) {\n      return body.msg;\n    }\n    if ('error' in body) {\n      const error = body.error;\n      if (typeof error === 'string') return error;\n      if (error && typeof error === 'object' && 'message' in error) {\n        return String((error as { message: unknown }).message);\n      }\n    }\n  } catch {\n    try {\n      const text = await resp.text();\n      if (text) return text;\n    } catch {\n      // Ignore and return fallback below.\n    }\n  }\n  return fallback;\n}\n```\n\n### Optional JSON Helper\n\nUse this helper for non-streaming `POST` calls. It returns the raw OpenRouter JSON on success.\n\n```typescript\nasync function callOpenRouterJSON<T>(url: string, apiKey: string, body: unknown): Promise<{ data?: T; error?: string }> {\n  let resp: Response;\n  try {\n    resp = await fetch(url, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },\n      body: JSON.stringify(body),\n    });\n  } catch {\n    return { error: 'Network error' };\n  }\n\n  if (!resp.ok) {\n    return { error: await extractPinmeOpenRouterError(resp) };\n  }\n\n  return { data: await resp.json() as T };\n}\n```\n\n### Usage Example\n\n```typescript\nconst baseUrl = env.BASE_URL ?? 'https://pinme.cloud';\n\n// Call LLM (non-streaming)\nconst llmResult = await callOpenRouterJSON<{ choices: Array<{ message: { content: string } }> }>(\n  `${baseUrl}/api/v1/chat/completions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`, env.API_KEY,\n  { model: 'openai/gpt-4o-mini', messages: [{ role: 'user', content: 'Hi' }] },\n);\nif (llmResult.error) return json({ error: llmResult.error }, 502);\n```","author":"@glitternetwork","ownerProfile":null,"authorContacts":null,"sourceUrl":"https://github.com/glitternetwork/pinme/tree/main/skills/pinme-llm","license":"MIT","category":"coding","lang":"en","tokens":2783,"stars":0,"calls30d":2,"claimed":false,"visibility":"public","origin":"crawler","version":"0.1.0","createdAt":"2026-08-22","updatedAt":"2026-08-22","files":[],"requires":{"mcp":[],"tools":[]},"safety":{"flags":[],"scannedAt":"2026-08-22","hasScripts":false,"networkEndpoints":["pinme.cloud"]}}