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
20 changes: 17 additions & 3 deletions src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,27 @@ export function parsePatchIds(input: string | string[] | undefined): string[] {

const CHAT_TIMEOUT_MS = 15 * 60_000; // apply + two generations on a busy runtime

/**
* The repository ID inside a Hugging Face model URL.
*
* Most repositories are `<owner>/<model>`, but the oldest and most-used models are canonical: they
* live at the root of the Hub with no owner segment, and their ID is the bare name a runtime serves
* them under — `gpt2`, `bert-base-uncased`, `distilgpt2`. Requiring two segments refused those, and
* the rewritten `openai-community/gpt2` form is a different string, so a node serving `gpt2` would
* have rejected it as a different model. Both shapes are accepted; a dataset, Space or file URL is
* still refused, and so is anything with credentials, a query or a fragment.
*/
export function huggingFaceModelId(input: string): string {
let url: URL;
try { url = new URL(input); } catch { throw new CliError('Expected a Hugging Face model repository URL'); }
const match = /^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/?$/.exec(url.pathname);
const segment = '[A-Za-z0-9_.-]+';
const match = new RegExp(`^/(?:(${segment})/)?(${segment})/?$`).exec(url.pathname);
const reserved = ['datasets', 'spaces', 'models', 'organizations', 'settings', 'docs', 'blog', 'api'];
if (url.origin !== 'https://huggingface.co' || url.username || url.password || url.search || url.hash
|| !match || ['datasets', 'spaces'].includes(match[1])) throw new CliError('Use https://huggingface.co/<owner>/<model>, not a dataset, Space or file URL');
return `${match[1]}/${match[2]}`;
|| !match || reserved.includes(match[1] ?? match[2])) {
throw new CliError('Use https://huggingface.co/<owner>/<model> (or a canonical https://huggingface.co/<model>), not a dataset, Space or file URL');
}
return match[1] ? `${match[1]}/${match[2]}` : match[2];
}

function chatIds(input: string | string[], opts: ChatArgs): string[] {
Expand Down
10 changes: 10 additions & 0 deletions test/huggingface-model-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ import { huggingFaceModelId } from '../src/commands/chat.js';

test('model URL parsing rejects datasets, Spaces, credentials and ambiguous revisions', () => {
assert.equal(huggingFaceModelId('https://huggingface.co/owner/model/'), 'owner/model');
// Canonical repositories have no owner segment and their ID is the bare name a runtime serves
// them under. The rewritten `openai-community/gpt2` is a different string, so a node serving
// `gpt2` must be reachable by `gpt2`.
assert.equal(huggingFaceModelId('https://huggingface.co/gpt2'), 'gpt2');
assert.equal(huggingFaceModelId('https://huggingface.co/bert-base-uncased/'), 'bert-base-uncased');
assert.equal(huggingFaceModelId('https://huggingface.co/openai-community/gpt2'), 'openai-community/gpt2');
for (const url of ['https://huggingface.co/datasets', 'https://huggingface.co/spaces', 'https://huggingface.co/docs',
'https://huggingface.co/models', 'https://huggingface.co/settings']) {
assert.throws(() => huggingFaceModelId(url), undefined, url);
}
for (const url of ['http://huggingface.co/owner/model', 'https://huggingface.co/datasets/model', 'https://huggingface.co/spaces/model',
'https://example.com/owner/model', 'https://secret@huggingface.co/owner/model', 'https://huggingface.co/owner/model/tree/main',
'https://huggingface.co/owner/model?revision=main', 'https://huggingface.co/owner/model#revision']) {
Expand Down