Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions lat.md/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::<url>` — test-only replay server for offline testing

Expand Down
32 changes: 27 additions & 5 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ' +
Expand Down Expand Up @@ -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): `);
Expand All @@ -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_...') +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe mention all three possible prefixes ghp_... / gho_... / github_pat_... here?

') 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.');
}
Expand Down
3 changes: 2 additions & 1 deletion src/search/embeddings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
}

Expand Down
21 changes: 19 additions & 2 deletions src/search/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type EmbeddingProvider = {
model: string;
dimensions: number;
headers: (key: string) => Record<string, string>;
errorHint?: string;
};

const openai: EmbeddingProvider = {
Expand All @@ -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);
Expand All @@ -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_...).`,
);
}
2 changes: 1 addition & 1 deletion templates/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 78 additions & 2 deletions tests/search.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe move the import to the top?


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$/,
);
});
});

Expand Down