[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M71PZF6DMH86X1YYFXGY9RAG":3,"skill-same:01M71PZF6DMH86X1YYFXGY9RAG":68},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":15,"categories":16,"keywords":22,"publishedAt":42,"linkOnly":43,"markdownContent":44,"markdownBody":45,"frontMatter":46,"refName":47,"commitSha":48,"skillDirectory":49,"forksCount":50,"repositoryHtmlUrl":51,"repositoryHomepageUrl":6,"ownerHtmlUrl":52,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":53,"install":54,"resources":67},"01M71PZF6DMH86X1YYFXGY9RAG","pinme-llm",null,"Use this skill when a PinMe project (Worker TypeScript) needs to call OpenRouter-backed LLM APIs, including models, chat\u002Fcompletions, streaming, or OpenRouter web search. Guides AI to generate correct Worker TS code.","developAgent\u002Fbmad-start","developAgent","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F264530239?v=4","01M71J8X6CY24V5ZZHFGETRMSW","01M71J8X739FS28DGQAAY7V4QR",1,"MIT","https:\u002F\u002Fgithub.com\u002FdevelopAgent\u002Fbmad-start\u002Fblob\u002Fmain\u002F.pi\u002Fskills\u002Fpinme-llm\u002FSKILL.md",[17],{"categoryNo":18,"categoryName":19,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000030","开发工程师","PROFESSION","适用职业",[23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41],"pinme","pinme llm","typescript","models","generate","web","openrouter","chat","streaming","guides","code","when","use","call","worker","correct","search","web search","project","2026-11-10 13:02:02",false,"---\nname: pinme-llm\ndescription: Use this skill when a PinMe project (Worker TypeScript) needs to call OpenRouter-backed LLM APIs, including models, chat\u002Fcompletions, streaming, or OpenRouter web search. Guides AI to generate correct Worker TS code.\n---\n\n# 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\u002F\u002F backend\u002Fsrc\u002Fworker.ts\nexport interface Env {\n  DB: D1Database;\n  API_KEY: string;       \u002F\u002F Project API Key from create_worker\n  PROJECT_NAME: string;  \u002F\u002F Actual project_name from create_worker; must match API_KEY\n  BASE_URL?: string;     \u002F\u002F Optional override for PinMe API base URL, defaults to https:\u002F\u002Fpinme.cloud\n}\n```\n\n> `API_KEY` authenticates the Worker to PinMe. `PROJECT_NAME` is required for `chat\u002Fcompletions` and must belong to the same project as `API_KEY`. When `BASE_URL` is not set, use `https:\u002F\u002Fpinme.cloud`.\n\n---\n\n## Models API\n\n**Endpoint:** `GET {BASE_URL}\u002Fapi\u002Fv1\u002Fmodels`\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 `\u002Fmodels`.\n\n```typescript\nasync function listModels(env: Env): Promise\u003Cunknown> {\n  const baseUrl = env.BASE_URL ?? 'https:\u002F\u002Fpinme.cloud';\n  const resp = await fetch(`${baseUrl}\u002Fapi\u002Fv1\u002Fmodels`, {\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}\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions?project_name={project_name}`\n**Authentication:** `X-API-Key` header (using `env.API_KEY`)\n**Request Body:** OpenRouter chat\u002Fcompletions 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\u002Fgpt-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 \u002Fapi\u002Fv1\u002Fmodels` 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\u002Fcompletions`; 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\u003Cstring> {\n  const baseUrl = env.BASE_URL ?? 'https:\u002F\u002Fpinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application\u002Fjson',\n        'X-API-Key': env.API_KEY,\n      },\n      body: JSON.stringify({\n        model: 'openai\u002Fgpt-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\u003C{ 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\"` \u002F `\"Invalid API key\"` \u002F `\"Invalid API key or project name\"` |\n| 400 | project_name missing or OpenRouter key not configured | `\"project_name is required\"` \u002F `\"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\u002F5xx, 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\u003C{ role: string; content: string }>,\n  model = 'openai\u002Fgpt-4o-mini',\n): Promise\u003C{ content: string; error?: string }> {\n  const baseUrl = env.BASE_URL ?? 'https:\u002F\u002Fpinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application\u002Fjson',\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\u003C{ message: { content: string } }> };\n  return { content: data.choices[0]?.message?.content || '' };\n}\n\n\u002F\u002F Usage in routes\nasync function handleChat(request: Request, env: Env): Promise\u003CResponse> {\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\u003CResponse> {\n  const body = await request.text();\n  const baseUrl = env.BASE_URL ?? 'https:\u002F\u002Fpinme.cloud';\n\n  \u002F\u002F Ensure stream=true in the request\n  let parsed = JSON.parse(body);\n  parsed.stream = true;\n\n  const resp = await fetch(\n    `${baseUrl}\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application\u002Fjson',\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  \u002F\u002F Pass through SSE stream directly\n  return new Response(resp.body, {\n    status: 200,\n    headers: {\n      'Content-Type': 'text\u002Fevent-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\u003Cvoid> {\n  const resp = await fetch(getApiUrl('\u002Fapi\u002Fchat\u002Fstream'), {\n    method: 'POST',\n    headers: { 'Content-Type': 'application\u002Fjson' },\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()!; \u002F\u002F 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\u003C{ 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 `\u002Fapi\u002Fv1\u002Fmodels` and `\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions`, successful responses are raw OpenRouter responses. Proxy failures before the OpenRouter request use PinMe's wrapped error format:\n\n```typescript\ninterface PinmeResponse\u003CT = unknown> {\n  code: number;   \u002F\u002F 200=success, other=failure\n  msg: string;    \u002F\u002F \"ok\" | \"error\" | \"invalid params\"\n  data?: T;       \u002F\u002F 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\u003Cstring> {\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      \u002F\u002F 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\u003CT>(url: string, apiKey: string, body: unknown): Promise\u003C{ data?: T; error?: string }> {\n  let resp: Response;\n  try {\n    resp = await fetch(url, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application\u002Fjson', '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:\u002F\u002Fpinme.cloud';\n\n\u002F\u002F Call LLM (non-streaming)\nconst llmResult = await callOpenRouterJSON\u003C{ choices: Array\u003C{ message: { content: string } }> }>(\n  `${baseUrl}\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`, env.API_KEY,\n  { model: 'openai\u002Fgpt-4o-mini', messages: [{ role: 'user', content: 'Hi' }] },\n);\nif (llmResult.error) return json({ error: llmResult.error }, 502);\n```\n","# 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\u002F\u002F backend\u002Fsrc\u002Fworker.ts\nexport interface Env {\n  DB: D1Database;\n  API_KEY: string;       \u002F\u002F Project API Key from create_worker\n  PROJECT_NAME: string;  \u002F\u002F Actual project_name from create_worker; must match API_KEY\n  BASE_URL?: string;     \u002F\u002F Optional override for PinMe API base URL, defaults to https:\u002F\u002Fpinme.cloud\n}\n```\n\n> `API_KEY` authenticates the Worker to PinMe. `PROJECT_NAME` is required for `chat\u002Fcompletions` and must belong to the same project as `API_KEY`. When `BASE_URL` is not set, use `https:\u002F\u002Fpinme.cloud`.\n\n---\n\n## Models API\n\n**Endpoint:** `GET {BASE_URL}\u002Fapi\u002Fv1\u002Fmodels`\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 `\u002Fmodels`.\n\n```typescript\nasync function listModels(env: Env): Promise\u003Cunknown> {\n  const baseUrl = env.BASE_URL ?? 'https:\u002F\u002Fpinme.cloud';\n  const resp = await fetch(`${baseUrl}\u002Fapi\u002Fv1\u002Fmodels`, {\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}\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions?project_name={project_name}`\n**Authentication:** `X-API-Key` header (using `env.API_KEY`)\n**Request Body:** OpenRouter chat\u002Fcompletions 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\u002Fgpt-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 \u002Fapi\u002Fv1\u002Fmodels` 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\u002Fcompletions`; 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\u003Cstring> {\n  const baseUrl = env.BASE_URL ?? 'https:\u002F\u002Fpinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application\u002Fjson',\n        'X-API-Key': env.API_KEY,\n      },\n      body: JSON.stringify({\n        model: 'openai\u002Fgpt-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\u003C{ 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\"` \u002F `\"Invalid API key\"` \u002F `\"Invalid API key or project name\"` |\n| 400 | project_name missing or OpenRouter key not configured | `\"project_name is required\"` \u002F `\"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\u002F5xx, 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\u003C{ role: string; content: string }>,\n  model = 'openai\u002Fgpt-4o-mini',\n): Promise\u003C{ content: string; error?: string }> {\n  const baseUrl = env.BASE_URL ?? 'https:\u002F\u002Fpinme.cloud';\n  const resp = await fetch(\n    `${baseUrl}\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application\u002Fjson',\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\u003C{ message: { content: string } }> };\n  return { content: data.choices[0]?.message?.content || '' };\n}\n\n\u002F\u002F Usage in routes\nasync function handleChat(request: Request, env: Env): Promise\u003CResponse> {\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\u003CResponse> {\n  const body = await request.text();\n  const baseUrl = env.BASE_URL ?? 'https:\u002F\u002Fpinme.cloud';\n\n  \u002F\u002F Ensure stream=true in the request\n  let parsed = JSON.parse(body);\n  parsed.stream = true;\n\n  const resp = await fetch(\n    `${baseUrl}\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`,\n    {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application\u002Fjson',\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  \u002F\u002F Pass through SSE stream directly\n  return new Response(resp.body, {\n    status: 200,\n    headers: {\n      'Content-Type': 'text\u002Fevent-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\u003Cvoid> {\n  const resp = await fetch(getApiUrl('\u002Fapi\u002Fchat\u002Fstream'), {\n    method: 'POST',\n    headers: { 'Content-Type': 'application\u002Fjson' },\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()!; \u002F\u002F 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\u003C{ 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 `\u002Fapi\u002Fv1\u002Fmodels` and `\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions`, successful responses are raw OpenRouter responses. Proxy failures before the OpenRouter request use PinMe's wrapped error format:\n\n```typescript\ninterface PinmeResponse\u003CT = unknown> {\n  code: number;   \u002F\u002F 200=success, other=failure\n  msg: string;    \u002F\u002F \"ok\" | \"error\" | \"invalid params\"\n  data?: T;       \u002F\u002F 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\u003Cstring> {\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      \u002F\u002F 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\u003CT>(url: string, apiKey: string, body: unknown): Promise\u003C{ data?: T; error?: string }> {\n  let resp: Response;\n  try {\n    resp = await fetch(url, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application\u002Fjson', '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:\u002F\u002Fpinme.cloud';\n\n\u002F\u002F Call LLM (non-streaming)\nconst llmResult = await callOpenRouterJSON\u003C{ choices: Array\u003C{ message: { content: string } }> }>(\n  `${baseUrl}\u002Fapi\u002Fv1\u002Fchat\u002Fcompletions?project_name=${encodeURIComponent(env.PROJECT_NAME)}`, env.API_KEY,\n  { model: 'openai\u002Fgpt-4o-mini', messages: [{ role: 'user', content: 'Hi' }] },\n);\nif (llmResult.error) return json({ error: llmResult.error }, 502);\n```\n",{"name":5,"description":7},"main","1907bbe850fb749efa8b499b8da7ebb78a663519",".pi\u002Fskills\u002Fpinme-llm",0,"https:\u002F\u002Fgithub.com\u002FdevelopAgent\u002Fbmad-start","https:\u002F\u002Fgithub.com\u002FdevelopAgent","2026-07-19 05:19:22",{"source":8,"skillName":5,"commands":55,"prompt":65,"zipAvailable":66},[56,59,62],{"tool":57,"command":58},"npx","npx skills add developAgent\u002Fbmad-start --skill pinme-llm",{"tool":60,"command":61},"bunx","bunx skills add developAgent\u002Fbmad-start --skill pinme-llm",{"tool":63,"command":64},"pnpm","pnpm dlx skills add developAgent\u002Fbmad-start --skill pinme-llm","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FdevelopAgent\u002Fbmad-start\u002Fblob\u002Fmain\u002F.pi\u002Fskills\u002Fpinme-llm\u002FSKILL.md",true,[],[69,87,116,134,148,174],{"skillNo":70,"name":71,"nameZh":6,"description":72,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":73,"categories":74,"keywords":78,"publishedAt":42},"01M71R3M9DJTTY0ARFQ6K04NMP","gds-correct-course","Manage significant changes during sprint execution. Use when the user says \"correct course\" or \"propose sprint change\"","https:\u002F\u002Fgithub.com\u002FdevelopAgent\u002Fbmad-start\u002Fblob\u002Fmain\u002F.rovodev\u002Fskills\u002Fgds-correct-course\u002FSKILL.md",[75],{"categoryNo":76,"categoryName":77,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000039","教师与教育",[79,80,38,81,82,83,34,35,84,85,86],"gds correct course","course","user","changes","change","execution","manage","sprint",{"skillNo":88,"name":89,"nameZh":6,"description":90,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":91,"categories":92,"keywords":99,"publishedAt":42},"01M71R3GENSFD1A1XVZTTQG1KF","gds-code-review","Review code changes adversarially using parallel review layers (Blind Hunter, Edge Case Hunter, Acceptance Auditor) with structured triage into actionable categories. Use when the user says \"run code review\" or \"review this code\"","https:\u002F\u002Fgithub.com\u002FdevelopAgent\u002Fbmad-start\u002Fblob\u002Fmain\u002F.rovodev\u002Fskills\u002Fgds-code-review\u002FSKILL.md",[93,98],{"categoryNo":94,"categoryName":95,"taxonomyType":96,"taxonomyTypeDesc":97},"01JBSEEDCAT000000000000007","代码审查","SCENARIO","使用场景",{"categoryNo":18,"categoryName":19,"taxonomyType":20,"taxonomyTypeDesc":21},[100,33,101,102,103,82,104,105,106,107,108,109,110,34,111,35,112,113,114,115],"code review","review","gds code review","categories","triage","review code","run","parallel","case","with","run code","acceptance","edge","actionable","layers","hunter",{"skillNo":117,"name":118,"nameZh":6,"description":119,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":120,"categories":121,"keywords":122,"publishedAt":42},"01M71R3DD098MQRD6F8S4GG0AW","gds-check-implementation-readiness","Verify GDD, UX, Architecture, and Epics alignment before production. Use when the user says \"check readiness\" or \"implementation readiness\"","https:\u002F\u002Fgithub.com\u002FdevelopAgent\u002Fbmad-start\u002Fblob\u002Fmain\u002F.rovodev\u002Fskills\u002Fgds-check-implementation-readiness\u002FSKILL.md",[],[123,124,125,126,127,128,81,129,130,131,132,34,35,133],"check","readiness","Implementation Readiness","gds check implementation readiness","implementation","check readiness","Production","ux","verify","architecture","alignment",{"skillNo":135,"name":136,"nameZh":6,"description":137,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":138,"categories":139,"keywords":140,"publishedAt":42},"01M71R3ACAA1G0NVQ330JQNJPK","gds-brainstorm-game","Facilitate game brainstorming sessions with game-specific techniques. Use when the user says \"brainstorm game\" or \"game ideas\"","https:\u002F\u002Fgithub.com\u002FdevelopAgent\u002Fbmad-start\u002Fblob\u002Fmain\u002F.rovodev\u002Fskills\u002Fgds-brainstorm-game\u002FSKILL.md",[],[141,142,143,81,144,109,34,35,145,146,147],"gds brainstorm game","game","brainstorm","brainstorming","techniques","sessions","ideas",{"skillNo":149,"name":150,"nameZh":6,"description":151,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":152,"categories":153,"keywords":157,"publishedAt":42},"01M71R377MJ6WJTTFARGKH3HX7","gds-agent-tech-writer","Technical documentation specialist and knowledge curator for game projects. Use when the user asks to talk to Paige or requests the Technical Writer.","https:\u002F\u002Fgithub.com\u002FdevelopAgent\u002Fbmad-start\u002Fblob\u002Fmain\u002F.rovodev\u002Fskills\u002Fgds-agent-tech-writer\u002FSKILL.md",[154],{"categoryNo":155,"categoryName":156,"taxonomyType":96,"taxonomyTypeDesc":97},"01JBSEEDCAT000000000000023","Skill与智能体开发",[158,159,160,161,35,162,163,164,165,34,81,166,167,168,169,170,171,172,142,173],"gds agent tech writer","tech","writer","technical writer","technical","knowledge","for","projects","technical documentation","talk","specialist","documentation specialist","knowledge curator","requests","documentation","curator",{"skillNo":175,"name":176,"nameZh":6,"description":177,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":178,"categories":179,"keywords":181,"publishedAt":42},"01M71R34RGT2ZX3TSFA2CRT0NC","gds-agent-game-solo-dev","Elite indie game developer for rapid prototyping and solo quick-flow development. Use when the user asks to talk to Indie or requests the Game Solo Dev.","https:\u002F\u002Fgithub.com\u002FdevelopAgent\u002Fbmad-start\u002Fblob\u002Fmain\u002F.rovodev\u002Fskills\u002Fgds-agent-game-solo-dev\u002FSKILL.md",[180],{"categoryNo":155,"categoryName":156,"taxonomyType":96,"taxonomyTypeDesc":97},[182,142,183,184,185,186,81,167,171,187,188,34,35,189,164,190,191],"solo","gds agent game solo dev","dev","flow","developer","RAPID","game developer","prototyping","development","quick"]