From df3216a37b4f47dc650e2ad5a235476f7f8ea037 Mon Sep 17 00:00:00 2001 From: Quetzal Bradley Date: Wed, 1 Apr 2026 17:10:44 -0700 Subject: [PATCH] Add GitHub token support for semantic search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot subscribers can now use their GitHub token for `lat search` embeddings instead of a separate OpenAI or Vercel API key. Problem ------- Semantic search required an OpenAI (`sk-...`) or Vercel AI Gateway (`vck_...`) API key. Users with a GitHub Copilot subscription already have access to the same `text-embedding-3-small` model via the GitHub Models API, but couldn't use it. Solution -------- Add a `github` embedding provider that targets the GitHub Models API at `https://models.github.ai/inference/embeddings`. The API is OpenAI-compatible, so the existing `embed()` function works unchanged. Provider detection recognizes three GitHub token prefixes: - `ghp_` — classic personal access tokens - `gho_` — OAuth tokens (returned by `gh auth token`) - `github_pat_` — fine-grained personal access tokens Other changes: - Add `errorHint` field to `EmbeddingProvider` type, appended to API error messages to give provider-specific troubleshooting guidance - Update `lat init` to accept GitHub tokens with `models:read` scope guidance and a `gh auth token` helper tip - Update all error messages (Anthropic rejection, unrecognized prefix) to reference GitHub as an option - Update README, lat.md/cli.md, and AGENTS.md documentation Validation ---------- - 15 provider detection tests (3 new GitHub prefix tests with full config assertions, updated Anthropic/unknown error tests, negative test for `ghs_` App tokens) - 2 new `errorHint` integration tests (mock fetch, verify hint appended on error / absent when unset) - All 159 tests pass; `tsc --noEmit` and `lat check` clean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 4 +- lat.md/cli.md | 1 + src/cli/init.ts | 32 +++++++++++++--- src/search/embeddings.ts | 3 +- src/search/provider.ts | 21 ++++++++++- templates/AGENTS.md | 2 +- tests/search.test.ts | 80 +++++++++++++++++++++++++++++++++++++++- 7 files changed, 131 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a7711e3..833e69c 100644 --- a/README.md +++ b/README.md @@ -72,13 +72,15 @@ lat mcp # start MCP server for editor integration ## Configuration -Semantic search (`lat search`) requires an OpenAI (`sk-...`) or Vercel AI Gateway (`vck_...`) API key. The key is resolved in order: +Semantic search (`lat search`) requires an OpenAI (`sk-...`), Vercel AI Gateway (`vck_...`), or GitHub (`ghp_...`, `gho_...`, `github_pat_...`) API key. The key is resolved in order: 1. `LAT_LLM_KEY` env var — direct value 2. `LAT_LLM_KEY_FILE` env var — path to a file containing the key 3. `LAT_LLM_KEY_HELPER` env var — shell command that prints the key (10s timeout) 4. Config file — saved by `lat init`. Run `lat config` to see its location. +> **Tip:** If you use the GitHub CLI, `LAT_LLM_KEY_HELPER="gh auth token"` keeps the key in sync with your `gh` login. Requires a Copilot subscription and a token with `models:read` scope. + ## Development Requires Node.js 22+ and pnpm. diff --git a/lat.md/cli.md b/lat.md/cli.md index be0e157..5acedd4 100644 --- a/lat.md/cli.md +++ b/lat.md/cli.md @@ -321,6 +321,7 @@ Provider is auto-detected from the resolved key prefix: - `sk-...` — OpenAI (uses `text-embedding-3-small`, 1536 dims) - `vck_...` — Vercel AI Gateway (uses `openai/text-embedding-3-small`, 1536 dims) +- `ghp_...` / `gho_...` / `github_pat_...` — GitHub Models API (uses `openai/text-embedding-3-small`, 1536 dims; requires Copilot subscription) - `sk-ant-...` — Anthropic (not supported, errors with guidance) - `REPLAY_LAT_LLM_KEY::` — test-only replay server for offline testing diff --git a/src/cli/init.ts b/src/cli/init.ts index 9490cdc..91ebbc6 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -1099,7 +1099,7 @@ async function setupLlmKey( ' relevant documentation by meaning, not just keywords. This requires an', ); console.log( - ' embedding API key (OpenAI or Vercel AI Gateway). Without it, agents can still', + ' embedding API key (OpenAI, Vercel AI Gateway, or GitHub). Without it, agents can still', ); console.log( ' use ' + @@ -1127,10 +1127,24 @@ async function setupLlmKey( console.log( ' Supported: OpenAI (' + styleText('dim', 'sk-...') + - ') or Vercel AI Gateway (' + + '), Vercel AI Gateway (' + styleText('dim', 'vck_...') + + '), or GitHub (' + + styleText('dim', 'ghp_... / gho_... / github_pat_...') + ')', ); + console.log( + styleText( + 'dim', + ' GitHub: requires a token with models:read scope and a Copilot subscription.', + ), + ); + console.log( + styleText( + 'dim', + ' Tip: set LAT_LLM_KEY_HELPER="gh auth token" if you use the GitHub CLI.', + ), + ); console.log(''); const key = await prompt(rl, ` Paste your key (or press Enter to skip): `); @@ -1156,17 +1170,25 @@ async function setupLlmKey( console.log( ' lat.md needs an OpenAI (' + styleText('dim', 'sk-...') + - ') or Vercel AI Gateway (' + + '), Vercel AI Gateway (' + styleText('dim', 'vck_...') + + '), or GitHub (' + + styleText('dim', 'ghp_...') + ') key.', ); return; } - if (!key.startsWith('sk-') && !key.startsWith('vck_')) { + if ( + !key.startsWith('sk-') && + !key.startsWith('vck_') && + !key.startsWith('ghp_') && + !key.startsWith('gho_') && + !key.startsWith('github_pat_') + ) { console.log( styleText('yellow', ' Unrecognized key prefix.') + - ' Expected sk-... (OpenAI) or vck_... (Vercel AI Gateway).', + ' Expected sk-... (OpenAI), vck_... (Vercel AI Gateway), or ghp_.../gho_.../github_pat_... (GitHub).', ); console.log(' Saving anyway — you can update it later.'); } diff --git a/src/search/embeddings.ts b/src/search/embeddings.ts index daa2f03..7fcdb32 100644 --- a/src/search/embeddings.ts +++ b/src/search/embeddings.ts @@ -22,8 +22,9 @@ export async function embed( if (!resp.ok) { const body = await resp.text(); + const hint = provider.errorHint ? `\n${provider.errorHint}` : ''; throw new Error( - `Embedding API error (${resp.status}): ${body.slice(0, 200)}`, + `Embedding API error (${resp.status}): ${body.slice(0, 200)}${hint}`, ); } diff --git a/src/search/provider.ts b/src/search/provider.ts index 16ee2b6..5d71540 100644 --- a/src/search/provider.ts +++ b/src/search/provider.ts @@ -4,6 +4,7 @@ export type EmbeddingProvider = { model: string; dimensions: number; headers: (key: string) => Record; + errorHint?: string; }; const openai: EmbeddingProvider = { @@ -28,6 +29,19 @@ const vercel: EmbeddingProvider = { }), }; +const github: EmbeddingProvider = { + name: 'github', + apiBase: 'https://models.github.ai/inference', + model: 'openai/text-embedding-3-small', + dimensions: 1536, + headers: (key) => ({ + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + }), + errorHint: + 'Ensure your GitHub token has the models:read permission and your account has a Copilot subscription.', +}; + export function detectProvider(key: string): EmbeddingProvider { if (key.startsWith('REPLAY_LAT_LLM_KEY::')) { const replayUrl = key.slice('REPLAY_LAT_LLM_KEY::'.length); @@ -41,12 +55,15 @@ export function detectProvider(key: string): EmbeddingProvider { } if (key.startsWith('sk-ant-')) { throw new Error( - "Anthropic doesn't offer an embedding model. Set LAT_LLM_KEY to an OpenAI (sk-...) or Vercel AI Gateway (vck_...) key.", + "Anthropic doesn't offer an embedding model. Set LAT_LLM_KEY to an OpenAI (sk-...), Vercel AI Gateway (vck_...), or GitHub (ghp_...) key.", ); } if (key.startsWith('vck_')) return vercel; + if (key.startsWith('ghp_')) return github; + if (key.startsWith('gho_')) return github; + if (key.startsWith('github_pat_')) return github; if (key.startsWith('sk-')) return openai; throw new Error( - `Unrecognized LAT_LLM_KEY prefix. Supported: OpenAI (sk-...), Vercel AI Gateway (vck_...).`, + `Unrecognized LAT_LLM_KEY prefix. Supported: OpenAI (sk-...), Vercel AI Gateway (vck_...), GitHub (ghp_..., gho_..., github_pat_...).`, ); } diff --git a/templates/AGENTS.md b/templates/AGENTS.md index c544150..be99ea6 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -29,7 +29,7 @@ lat check # validate all links and code refs Run `lat --help` when in doubt about available commands or options. -If `lat search` fails because no API key is configured, explain to the user that semantic search requires a key provided via `LAT_LLM_KEY` (direct value), `LAT_LLM_KEY_FILE` (path to key file), or `LAT_LLM_KEY_HELPER` (command that prints the key). Supported key prefixes: `sk-...` (OpenAI) or `vck_...` (Vercel). If the user doesn't want to set it up, use `lat locate` for direct lookups instead. +If `lat search` fails because no API key is configured, explain to the user that semantic search requires a key provided via `LAT_LLM_KEY` (direct value), `LAT_LLM_KEY_FILE` (path to key file), or `LAT_LLM_KEY_HELPER` (command that prints the key). Supported key prefixes: `sk-...` (OpenAI), `vck_...` (Vercel), or `ghp_...` / `gho_...` / `github_pat_...` (GitHub). If the user doesn't want to set it up, use `lat locate` for direct lookups instead. # Syntax primer diff --git a/tests/search.test.ts b/tests/search.test.ts index bc284dd..fc43fa2 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { mkdtempSync, rmSync, cpSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -27,12 +27,88 @@ describe('detectProvider', () => { expect(p.name).toBe('vercel'); }); + it('detects GitHub classic PAT', () => { + const p = detectProvider('ghp_abc123'); + expect(p.name).toBe('github'); + expect(p.apiBase).toBe('https://models.github.ai/inference'); + expect(p.model).toBe('openai/text-embedding-3-small'); + expect(p.dimensions).toBe(1536); + expect(p.errorHint).toBeDefined(); + }); + + it('detects GitHub fine-grained PAT', () => { + const p = detectProvider('github_pat_abc123'); + expect(p.name).toBe('github'); + expect(p.apiBase).toBe('https://models.github.ai/inference'); + expect(p.model).toBe('openai/text-embedding-3-small'); + expect(p.dimensions).toBe(1536); + }); + + it('detects GitHub OAuth token (gh CLI)', () => { + const p = detectProvider('gho_abc123'); + expect(p.name).toBe('github'); + expect(p.apiBase).toBe('https://models.github.ai/inference'); + }); + it('rejects Anthropic key with helpful message', () => { expect(() => detectProvider('sk-ant-abc123')).toThrow(/Anthropic/); + expect(() => detectProvider('sk-ant-abc123')).toThrow(/ghp_/); }); - it('rejects unknown key', () => { + it('rejects unknown key with provider list', () => { expect(() => detectProvider('xyz_abc123')).toThrow(/Unrecognized/); + expect(() => detectProvider('xyz_abc123')).toThrow(/GitHub/); + }); + + it('rejects GitHub App token (ghs_)', () => { + expect(() => detectProvider('ghs_abc123')).toThrow(/Unrecognized/); + }); +}); + +// --- errorHint tests --- + +import { embed } from '../src/search/embeddings.js'; + +describe('embed errorHint', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('appends errorHint to API error when provider has one', async () => { + const mockProvider: EmbeddingProvider = { + name: 'test', + apiBase: 'http://localhost:99999', + model: 'test-model', + dimensions: 1536, + headers: () => ({ 'Content-Type': 'application/json' }), + errorHint: 'Check your token permissions.', + }; + + globalThis.fetch = (async () => + new Response('Unauthorized', { status: 401 })) as typeof fetch; + + await expect(embed(['hello'], mockProvider, 'fake-key')).rejects.toThrow( + /Check your token permissions/, + ); + }); + + it('does not append hint when provider has no errorHint', async () => { + const mockProvider: EmbeddingProvider = { + name: 'test', + apiBase: 'http://localhost:99999', + model: 'test-model', + dimensions: 1536, + headers: () => ({ 'Content-Type': 'application/json' }), + }; + + globalThis.fetch = (async () => + new Response('Unauthorized', { status: 401 })) as typeof fetch; + + await expect(embed(['hello'], mockProvider, 'fake-key')).rejects.toThrow( + /Embedding API error \(401\): Unauthorized$/, + ); }); });