copilot-proxy-api
Version:
Self-hosted GitHub Copilot bridge for Claude Code and Codex CLI with OpenAI Responses, Anthropic Messages, streaming resilience, and context management.
1 lines • 5.34 kB
Source Map (JSON)
{"version":3,"file":"pdf-3rCh0vDg.mjs","names":[],"sources":["../src/lib/pdf.ts"],"sourcesContent":["/**\n * PDF text extraction for Anthropic `document` blocks.\n *\n * Why this exists: GitHub Copilot has no native PDF understanding. Claude Code\n * emits raw `document` blocks (with base64-encoded PDF bytes) for attachments\n * <=3 MB; for larger PDFs it pre-extracts to images on its side. Since Copilot\n * also can't reliably understand images of PDF pages without significant\n * cost, we take the pragmatic approach: extract text in the proxy and inject\n * as a text block.\n *\n * Trade-offs (documented in README):\n * - Visual content (diagrams, scans, image-only PDFs) is lost.\n * - Tables/columns may flow incorrectly.\n * - Password-protected PDFs are rejected with a Claude-Code-recognizable\n * error message so the user gets actionable feedback.\n */\n\nimport consola from \"consola\"\nimport { PDFParse } from \"pdf-parse\"\n\nimport { HTTPError } from \"./error\"\n\n/** Claude Code's `errors.ts` matches \"password\" + \"protected\" / \"encrypted\"\n * to surface a friendly UX. Use a phrase that hits that pattern. */\nconst PASSWORD_PROTECTED_MESSAGE =\n \"Cannot read password-protected or encrypted PDF document\"\n\nconst INVALID_PDF_MESSAGE = \"Invalid or corrupted PDF document\"\n\nconst EMPTY_PDF_MESSAGE = \"PDF document contains no extractable text\"\n\n/** Trim text block to keep payloads sane. Most PDFs we see are <100 pages. */\nconst MAX_EXTRACTED_CHARS = 500_000\n\nexport interface ExtractedPdf {\n text: string\n pageCount: number\n truncated: boolean\n}\n\nfunction decodeBase64(data: string): Uint8Array {\n // Bun.atob returns a binary string; convert to Uint8Array.\n const binary = atob(data)\n const bytes = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.codePointAt(i) ?? 0\n }\n return bytes\n}\n\nfunction isLikelyPasswordProtected(error: unknown): boolean {\n const msg = (\n error instanceof Error ?\n error.message\n : String(error)).toLowerCase()\n return (\n msg.includes(\"password\")\n || msg.includes(\"encrypted\")\n || (msg.includes(\"invalid pdf\") && msg.includes(\"encrypt\"))\n )\n}\n\nfunction pdfErrorResponse(message: string): HTTPError {\n return new HTTPError(\n message,\n new Response(\n JSON.stringify({\n type: \"error\",\n error: {\n type: \"invalid_request_error\",\n message,\n },\n }),\n {\n status: 400,\n statusText: \"Bad Request\",\n headers: { \"content-type\": \"application/json\" },\n },\n ),\n )\n}\n\n/**\n * Extract text from a base64-encoded PDF. Throws HTTPError (Anthropic-shaped)\n * for invalid / password-protected / empty PDFs.\n */\nexport async function extractPdfText(\n base64Data: string,\n): Promise<ExtractedPdf> {\n let bytes: Uint8Array\n try {\n bytes = decodeBase64(base64Data)\n } catch {\n throw pdfErrorResponse(INVALID_PDF_MESSAGE)\n }\n\n // Magic byte check: PDFs start with \"%PDF-\"\n if (\n bytes.length < 5\n || bytes[0] !== 0x25\n || bytes[1] !== 0x50\n || bytes[2] !== 0x44\n || bytes[3] !== 0x46\n || bytes[4] !== 0x2d\n ) {\n throw pdfErrorResponse(INVALID_PDF_MESSAGE)\n }\n\n let parsed: { text: string; total: number }\n try {\n const parser = new PDFParse({ data: bytes })\n const result = await parser.getText()\n parsed = { text: result.text, total: result.total }\n } catch (error) {\n consola.warn(\"PDF parse failed:\", error)\n if (isLikelyPasswordProtected(error)) {\n throw pdfErrorResponse(PASSWORD_PROTECTED_MESSAGE)\n }\n throw pdfErrorResponse(INVALID_PDF_MESSAGE)\n }\n\n const text = parsed.text.trim()\n if (text.length === 0) {\n throw pdfErrorResponse(EMPTY_PDF_MESSAGE)\n }\n\n const truncated = text.length > MAX_EXTRACTED_CHARS\n return {\n text:\n truncated ?\n text.slice(0, MAX_EXTRACTED_CHARS)\n + \"\\n\\n[…document truncated for length…]\"\n : text,\n pageCount: parsed.total,\n truncated,\n }\n}\n"],"mappings":"qGAwBA,MAGM,EAAsB,oCAKtB,EAAsB,IAQ5B,SAAS,EAAa,EAA0B,CAE9C,IAAM,EAAS,KAAK,EAAK,CACnB,EAAQ,IAAI,WAAW,EAAO,OAAO,CAC3C,IAAK,IAAI,EAAI,EAAG,EAAI,EAAO,OAAQ,IACjC,EAAM,GAAK,EAAO,YAAY,EAAE,EAAI,EAEtC,OAAO,EAGT,SAAS,EAA0B,EAAyB,CAC1D,IAAM,GACJ,aAAiB,MACf,EAAM,QACN,OAAO,EAAM,EAAE,aAAa,CAChC,OACE,EAAI,SAAS,WAAW,EACrB,EAAI,SAAS,YAAY,EACxB,EAAI,SAAS,cAAc,EAAI,EAAI,SAAS,UAAU,CAI9D,SAAS,EAAiB,EAA4B,CACpD,OAAO,IAAI,EACT,EACA,IAAI,SACF,KAAK,UAAU,CACb,KAAM,QACN,MAAO,CACL,KAAM,wBACN,UACD,CACF,CAAC,CACF,CACE,OAAQ,IACR,WAAY,cACZ,QAAS,CAAE,eAAgB,mBAAoB,CAChD,CACF,CACF,CAOH,eAAsB,EACpB,EACuB,CACvB,IAAI,EACJ,GAAI,CACF,EAAQ,EAAa,EAAW,MAC1B,CACN,MAAM,EAAiB,EAAoB,CAI7C,GACE,EAAM,OAAS,GACZ,EAAM,KAAO,IACb,EAAM,KAAO,IACb,EAAM,KAAO,IACb,EAAM,KAAO,IACb,EAAM,KAAO,GAEhB,MAAM,EAAiB,EAAoB,CAG7C,IAAI,EACJ,GAAI,CAEF,IAAM,EAAS,MADA,IAAI,EAAS,CAAE,KAAM,EAAO,CAAC,CAChB,SAAS,CACrC,EAAS,CAAE,KAAM,EAAO,KAAM,MAAO,EAAO,MAAO,OAC5C,EAAO,CAKd,MAJA,EAAQ,KAAK,oBAAqB,EAAM,CACpC,EAA0B,EAAM,CAC5B,EAAiB,2DAA2B,CAE9C,EAAiB,EAAoB,CAG7C,IAAM,EAAO,EAAO,KAAK,MAAM,CAC/B,GAAI,EAAK,SAAW,EAClB,MAAM,EAAiB,4CAAkB,CAG3C,IAAM,EAAY,EAAK,OAAS,EAChC,MAAO,CACL,KACE,EACE,EAAK,MAAM,EAAG,EAAoB,CAChC;;mCACF,EACJ,UAAW,EAAO,MAClB,YACD"}