@ailoud/providers 1.0.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +224 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/audio/ffmpeg.d.ts +12 -0
- package/dist/audio/ffmpeg.js +81 -0
- package/dist/diarize/sherpaDiarizer.d.ts +26 -0
- package/dist/diarize/sherpaDiarizer.js +64 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +19 -0
- package/dist/llm/anthropic.d.ts +36 -0
- package/dist/llm/anthropic.js +92 -0
- package/dist/llm/claudeCli.d.ts +32 -0
- package/dist/llm/claudeCli.js +63 -0
- package/dist/llm/llamaCpp.d.ts +38 -0
- package/dist/llm/llamaCpp.js +90 -0
- package/dist/llm/models.d.ts +23 -0
- package/dist/llm/models.js +97 -0
- package/dist/llm/openAiCompatible.d.ts +35 -0
- package/dist/llm/openAiCompatible.js +84 -0
- package/dist/process/pager.d.ts +36 -0
- package/dist/process/pager.js +69 -0
- package/dist/process/run.d.ts +35 -0
- package/dist/process/run.js +101 -0
- package/dist/provision/download.d.ts +19 -0
- package/dist/provision/download.js +80 -0
- package/dist/provision/llamaInstall.d.ts +38 -0
- package/dist/provision/llamaInstall.js +75 -0
- package/dist/provision/packageManager.d.ts +50 -0
- package/dist/provision/packageManager.js +66 -0
- package/dist/provision/sherpaInstall.d.ts +34 -0
- package/dist/provision/sherpaInstall.js +78 -0
- package/dist/provision/whisperInstall.d.ts +61 -0
- package/dist/provision/whisperInstall.js +89 -0
- package/dist/store/sqliteStore.d.ts +64 -0
- package/dist/store/sqliteStore.js +433 -0
- package/dist/stt/whisperCpp.d.ts +50 -0
- package/dist/stt/whisperCpp.js +116 -0
- package/dist/system/nodeFs.d.ts +14 -0
- package/dist/system/nodeFs.js +80 -0
- package/dist/system/systemClock.d.ts +20 -0
- package/dist/system/systemClock.js +55 -0
- package/dist/vad/whisperVad.d.ts +14 -0
- package/dist/vad/whisperVad.js +58 -0
- package/package.json +48 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Summarizer } from '@ailoud/core';
|
|
2
|
+
export interface AnthropicOptions {
|
|
3
|
+
readonly baseUrl: string;
|
|
4
|
+
readonly model: string;
|
|
5
|
+
readonly contextTokens: number;
|
|
6
|
+
readonly maxOutputTokens: number;
|
|
7
|
+
readonly apiKey: string;
|
|
8
|
+
readonly fetchImpl?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Pulls the text out of a messages response.
|
|
12
|
+
*
|
|
13
|
+
* The content is a list of blocks, not a string, because a reply can contain
|
|
14
|
+
* more than prose. Only text blocks are joined; anything else is skipped
|
|
15
|
+
* rather than stringified, which would put "[object Object]" in a summary.
|
|
16
|
+
*/
|
|
17
|
+
export declare function extractAnthropicText(body: unknown): string;
|
|
18
|
+
/**
|
|
19
|
+
* Claude, through Anthropic's own API.
|
|
20
|
+
*
|
|
21
|
+
* A separate adapter rather than a base-url swap on the OpenAI one, because
|
|
22
|
+
* the two APIs are not the same shape: a different path, `x-api-key` instead
|
|
23
|
+
* of a bearer token, a required version header, and a reply whose text lives
|
|
24
|
+
* in a list of content blocks rather than in `choices[0].message.content`.
|
|
25
|
+
* Pretending otherwise would have produced an adapter that looked generic and
|
|
26
|
+
* worked for exactly one vendor.
|
|
27
|
+
*/
|
|
28
|
+
export declare class AnthropicSummarizer implements Summarizer {
|
|
29
|
+
private readonly options;
|
|
30
|
+
readonly name = "anthropic";
|
|
31
|
+
get model(): string;
|
|
32
|
+
private readonly fetchImpl;
|
|
33
|
+
constructor(options: AnthropicOptions);
|
|
34
|
+
get contextTokens(): number;
|
|
35
|
+
complete(prompt: string): Promise<string>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { EnvironmentError, FailureError } from '@ailoud/core';
|
|
2
|
+
/** As for the OpenAI adapter: a hosted call that has not returned in five minutes is not going to. */
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 5 * 60_000;
|
|
4
|
+
/**
|
|
5
|
+
* The API version header Anthropic requires on every request.
|
|
6
|
+
*
|
|
7
|
+
* Pinned, not omitted and not tracking the newest: the header is how Anthropic
|
|
8
|
+
* keeps a client working when the API changes, and leaving it out or moving it
|
|
9
|
+
* automatically would trade that guarantee away for nothing.
|
|
10
|
+
*/
|
|
11
|
+
const API_VERSION = '2023-06-01';
|
|
12
|
+
/**
|
|
13
|
+
* Pulls the text out of a messages response.
|
|
14
|
+
*
|
|
15
|
+
* The content is a list of blocks, not a string, because a reply can contain
|
|
16
|
+
* more than prose. Only text blocks are joined; anything else is skipped
|
|
17
|
+
* rather than stringified, which would put "[object Object]" in a summary.
|
|
18
|
+
*/
|
|
19
|
+
export function extractAnthropicText(body) {
|
|
20
|
+
const blocks = body.content;
|
|
21
|
+
const text = (blocks ?? [])
|
|
22
|
+
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
|
23
|
+
.map((block) => block.text)
|
|
24
|
+
.join('')
|
|
25
|
+
.trim();
|
|
26
|
+
if (text === '') {
|
|
27
|
+
throw new FailureError('Claude returned no text. The response contained no text blocks in content[].');
|
|
28
|
+
}
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Claude, through Anthropic's own API.
|
|
33
|
+
*
|
|
34
|
+
* A separate adapter rather than a base-url swap on the OpenAI one, because
|
|
35
|
+
* the two APIs are not the same shape: a different path, `x-api-key` instead
|
|
36
|
+
* of a bearer token, a required version header, and a reply whose text lives
|
|
37
|
+
* in a list of content blocks rather than in `choices[0].message.content`.
|
|
38
|
+
* Pretending otherwise would have produced an adapter that looked generic and
|
|
39
|
+
* worked for exactly one vendor.
|
|
40
|
+
*/
|
|
41
|
+
export class AnthropicSummarizer {
|
|
42
|
+
options;
|
|
43
|
+
name = 'anthropic';
|
|
44
|
+
get model() {
|
|
45
|
+
return this.options.model;
|
|
46
|
+
}
|
|
47
|
+
fetchImpl;
|
|
48
|
+
constructor(options) {
|
|
49
|
+
this.options = options;
|
|
50
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
51
|
+
}
|
|
52
|
+
get contextTokens() {
|
|
53
|
+
return this.options.contextTokens;
|
|
54
|
+
}
|
|
55
|
+
async complete(prompt) {
|
|
56
|
+
const url = `${this.options.baseUrl.replace(/\/+$/, '')}/messages`;
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
59
|
+
let response;
|
|
60
|
+
try {
|
|
61
|
+
response = await this.fetchImpl(url, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
headers: {
|
|
65
|
+
'content-type': 'application/json',
|
|
66
|
+
'x-api-key': this.options.apiKey,
|
|
67
|
+
'anthropic-version': API_VERSION,
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
model: this.options.model,
|
|
71
|
+
max_tokens: this.options.maxOutputTokens,
|
|
72
|
+
messages: [{ role: 'user', content: prompt }],
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw new EnvironmentError(controller.signal.aborted
|
|
78
|
+
? `${this.options.baseUrl} did not answer within ${REQUEST_TIMEOUT_MS / 60_000} minutes.`
|
|
79
|
+
: `could not reach ${this.options.baseUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
// The body distinguishes a bad key from an exhausted quota from an
|
|
86
|
+
// unknown model name, and the user needs to know which.
|
|
87
|
+
const detail = await response.text().catch(() => '');
|
|
88
|
+
throw new FailureError(`Anthropic returned HTTP ${response.status}${detail === '' ? '' : `: ${detail.slice(0, 400)}`}`);
|
|
89
|
+
}
|
|
90
|
+
return extractAnthropicText(await response.json());
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Summarizer } from '@ailoud/core';
|
|
2
|
+
import { run as defaultRunner } from '../process/run.js';
|
|
3
|
+
export interface ClaudeCliOptions {
|
|
4
|
+
readonly binary: string;
|
|
5
|
+
/** A Claude Code alias such as "sonnet" or "opus", or a full model id. */
|
|
6
|
+
readonly model: string;
|
|
7
|
+
readonly contextTokens: number;
|
|
8
|
+
readonly runner?: typeof defaultRunner;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Claude through the Claude Code CLI, billed to the user's subscription.
|
|
12
|
+
*
|
|
13
|
+
* The reason this exists alongside the API adapter: a Claude subscription is
|
|
14
|
+
* not an API key, and someone who pays for one should not have to pay twice to
|
|
15
|
+
* summarise their own recordings. The CLI is already authenticated, so ailoud
|
|
16
|
+
* borrows that rather than asking for a second credential.
|
|
17
|
+
*
|
|
18
|
+
* `--print` makes it answer and exit instead of opening a session. Tools are
|
|
19
|
+
* switched off explicitly with an empty allow-list: this is a text completion,
|
|
20
|
+
* and an agent that could read files or run commands while summarising a
|
|
21
|
+
* transcript would be a much larger thing than the job asks for -- and would
|
|
22
|
+
* do it in whatever directory ailoud happened to be run from.
|
|
23
|
+
*/
|
|
24
|
+
export declare class ClaudeCliSummarizer implements Summarizer {
|
|
25
|
+
private readonly options;
|
|
26
|
+
readonly name = "claude-cli";
|
|
27
|
+
get model(): string;
|
|
28
|
+
private readonly runner;
|
|
29
|
+
constructor(options: ClaudeCliOptions);
|
|
30
|
+
get contextTokens(): number;
|
|
31
|
+
complete(prompt: string): Promise<string>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { FailureError } from '@ailoud/core';
|
|
2
|
+
import { run as defaultRunner } from '../process/run.js';
|
|
3
|
+
/**
|
|
4
|
+
* A hosted model reached through a local process. Longer than the direct API
|
|
5
|
+
* adapters allow, because this one also pays for process startup and the
|
|
6
|
+
* CLI's own session setup, but far short of the local model's hour.
|
|
7
|
+
*/
|
|
8
|
+
const COMPLETE_TIMEOUT_MS = 10 * 60_000;
|
|
9
|
+
/**
|
|
10
|
+
* Claude through the Claude Code CLI, billed to the user's subscription.
|
|
11
|
+
*
|
|
12
|
+
* The reason this exists alongside the API adapter: a Claude subscription is
|
|
13
|
+
* not an API key, and someone who pays for one should not have to pay twice to
|
|
14
|
+
* summarise their own recordings. The CLI is already authenticated, so ailoud
|
|
15
|
+
* borrows that rather than asking for a second credential.
|
|
16
|
+
*
|
|
17
|
+
* `--print` makes it answer and exit instead of opening a session. Tools are
|
|
18
|
+
* switched off explicitly with an empty allow-list: this is a text completion,
|
|
19
|
+
* and an agent that could read files or run commands while summarising a
|
|
20
|
+
* transcript would be a much larger thing than the job asks for -- and would
|
|
21
|
+
* do it in whatever directory ailoud happened to be run from.
|
|
22
|
+
*/
|
|
23
|
+
export class ClaudeCliSummarizer {
|
|
24
|
+
options;
|
|
25
|
+
name = 'claude-cli';
|
|
26
|
+
get model() {
|
|
27
|
+
return this.options.model;
|
|
28
|
+
}
|
|
29
|
+
runner;
|
|
30
|
+
constructor(options) {
|
|
31
|
+
this.options = options;
|
|
32
|
+
this.runner = options.runner ?? defaultRunner;
|
|
33
|
+
}
|
|
34
|
+
get contextTokens() {
|
|
35
|
+
return this.options.contextTokens;
|
|
36
|
+
}
|
|
37
|
+
async complete(prompt) {
|
|
38
|
+
const result = await this.runner(this.options.binary, [
|
|
39
|
+
'--print',
|
|
40
|
+
'--model',
|
|
41
|
+
this.options.model,
|
|
42
|
+
// No tools. Summarising is a completion; an agent with file and shell
|
|
43
|
+
// access is not what was asked for, and the transcript is not a task
|
|
44
|
+
// for it to act on.
|
|
45
|
+
'--allowed-tools',
|
|
46
|
+
'',
|
|
47
|
+
],
|
|
48
|
+
// On stdin rather than as an argument. A transcript of any length passes
|
|
49
|
+
// ARG_MAX -- about a megabyte on macOS, less once the environment is
|
|
50
|
+
// counted -- and the spawn then fails with E2BIG, which is not something
|
|
51
|
+
// the user can fix.
|
|
52
|
+
{ timeoutMs: COMPLETE_TIMEOUT_MS, stdin: prompt });
|
|
53
|
+
if (result.code !== 0) {
|
|
54
|
+
throw new FailureError(`${this.options.binary} failed: ${result.stderr.trim() || `exit ${result.code}`}. ` +
|
|
55
|
+
'If it is not signed in, run it once on its own first.');
|
|
56
|
+
}
|
|
57
|
+
const text = result.stdout.trim();
|
|
58
|
+
if (text === '') {
|
|
59
|
+
throw new FailureError(`${this.options.binary} returned nothing. Check that it is signed in by running it alone.`);
|
|
60
|
+
}
|
|
61
|
+
return text;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Summarizer } from '@ailoud/core';
|
|
2
|
+
import { run as defaultRunner } from '../process/run.js';
|
|
3
|
+
export interface LlamaCppOptions {
|
|
4
|
+
readonly binary: string;
|
|
5
|
+
readonly modelPath: string;
|
|
6
|
+
readonly contextTokens: number;
|
|
7
|
+
/** Hard cap on the answer, so a model that starts looping cannot run forever. */
|
|
8
|
+
readonly maxOutputTokens: number;
|
|
9
|
+
readonly threads?: number;
|
|
10
|
+
readonly runner?: typeof defaultRunner;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Strips llama.cpp's own output from around the answer.
|
|
14
|
+
*
|
|
15
|
+
* With `-no-cnv --single-turn` the completion is what lands on stdout, but
|
|
16
|
+
* some builds echo the prompt back before it. Trimming rather than parsing:
|
|
17
|
+
* the output is prose, not a format, and a parser would be one more thing to
|
|
18
|
+
* break on the next release.
|
|
19
|
+
*/
|
|
20
|
+
export declare function cleanCompletion(stdout: string, prompt: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* A local model, run the way whisper.cpp is: one binary, one GGUF file,
|
|
23
|
+
* spawned per request.
|
|
24
|
+
*
|
|
25
|
+
* Per request rather than a resident server. It costs a model load each time
|
|
26
|
+
* -- seconds for a 3B -- but a CLI that leaves a daemon running is a CLI that
|
|
27
|
+
* then has to manage one, and nobody summarises in a tight loop.
|
|
28
|
+
*/
|
|
29
|
+
export declare class LlamaCppSummarizer implements Summarizer {
|
|
30
|
+
private readonly options;
|
|
31
|
+
readonly name = "llama.cpp";
|
|
32
|
+
get model(): string;
|
|
33
|
+
private readonly runner;
|
|
34
|
+
constructor(options: LlamaCppOptions);
|
|
35
|
+
get contextTokens(): number;
|
|
36
|
+
complete(prompt: string): Promise<string>;
|
|
37
|
+
private completeFrom;
|
|
38
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { FailureError } from '@ailoud/core';
|
|
5
|
+
import { run as defaultRunner } from '../process/run.js';
|
|
6
|
+
/**
|
|
7
|
+
* Generation can legitimately take a long time on a laptop CPU: a few
|
|
8
|
+
* thousand tokens from a 3B model is minutes, not seconds, and summarising a
|
|
9
|
+
* long meeting is exactly that. A tighter bound would kill work that was
|
|
10
|
+
* going to succeed.
|
|
11
|
+
*/
|
|
12
|
+
const COMPLETE_TIMEOUT_MS = 60 * 60_000;
|
|
13
|
+
/**
|
|
14
|
+
* Strips llama.cpp's own output from around the answer.
|
|
15
|
+
*
|
|
16
|
+
* With `-no-cnv --single-turn` the completion is what lands on stdout, but
|
|
17
|
+
* some builds echo the prompt back before it. Trimming rather than parsing:
|
|
18
|
+
* the output is prose, not a format, and a parser would be one more thing to
|
|
19
|
+
* break on the next release.
|
|
20
|
+
*/
|
|
21
|
+
export function cleanCompletion(stdout, prompt) {
|
|
22
|
+
let text = stdout;
|
|
23
|
+
const echoed = text.indexOf(prompt);
|
|
24
|
+
if (echoed !== -1)
|
|
25
|
+
text = text.slice(echoed + prompt.length);
|
|
26
|
+
return text.trim();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A local model, run the way whisper.cpp is: one binary, one GGUF file,
|
|
30
|
+
* spawned per request.
|
|
31
|
+
*
|
|
32
|
+
* Per request rather than a resident server. It costs a model load each time
|
|
33
|
+
* -- seconds for a 3B -- but a CLI that leaves a daemon running is a CLI that
|
|
34
|
+
* then has to manage one, and nobody summarises in a tight loop.
|
|
35
|
+
*/
|
|
36
|
+
export class LlamaCppSummarizer {
|
|
37
|
+
options;
|
|
38
|
+
name = 'llama.cpp';
|
|
39
|
+
get model() {
|
|
40
|
+
return basename(this.options.modelPath);
|
|
41
|
+
}
|
|
42
|
+
runner;
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.options = options;
|
|
45
|
+
this.runner = options.runner ?? defaultRunner;
|
|
46
|
+
}
|
|
47
|
+
get contextTokens() {
|
|
48
|
+
return this.options.contextTokens;
|
|
49
|
+
}
|
|
50
|
+
async complete(prompt) {
|
|
51
|
+
const dir = await mkdtemp(join(tmpdir(), 'ailoud-llm-'));
|
|
52
|
+
const promptPath = join(dir, 'prompt.txt');
|
|
53
|
+
try {
|
|
54
|
+
return await this.completeFrom(prompt, promptPath);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await rm(dir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async completeFrom(prompt, promptPath) {
|
|
61
|
+
await writeFile(promptPath, prompt, 'utf8');
|
|
62
|
+
const result = await this.runner(this.options.binary, [
|
|
63
|
+
'-m',
|
|
64
|
+
this.options.modelPath,
|
|
65
|
+
'-c',
|
|
66
|
+
String(this.options.contextTokens),
|
|
67
|
+
'-n',
|
|
68
|
+
String(this.options.maxOutputTokens),
|
|
69
|
+
// A completion, not a chat session: anything interactive would sit
|
|
70
|
+
// waiting for input that is never coming.
|
|
71
|
+
'-no-cnv',
|
|
72
|
+
'--single-turn',
|
|
73
|
+
...(this.options.threads === undefined ? [] : ['-t', String(this.options.threads)]),
|
|
74
|
+
// -f rather than -p: a prompt carrying a transcript does not fit in an
|
|
75
|
+
// argument. ARG_MAX is about a megabyte on macOS, less once the
|
|
76
|
+
// environment is counted, and the spawn then fails with E2BIG -- a
|
|
77
|
+
// failure the user can do nothing about.
|
|
78
|
+
'-f',
|
|
79
|
+
promptPath,
|
|
80
|
+
], { timeoutMs: COMPLETE_TIMEOUT_MS });
|
|
81
|
+
if (result.code !== 0) {
|
|
82
|
+
throw new FailureError(`${this.name} failed: ${result.stderr.trim() || `exit ${result.code}`}`);
|
|
83
|
+
}
|
|
84
|
+
const text = cleanCompletion(result.stdout, prompt);
|
|
85
|
+
if (text === '') {
|
|
86
|
+
throw new FailureError(`${this.name} returned nothing. The transcript may be too large for the configured context.`);
|
|
87
|
+
}
|
|
88
|
+
return text;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** One model a provider says it will accept. */
|
|
2
|
+
export interface ModelOption {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
/** What to show in the picker: the vendor's display name where it gives one, else the id. */
|
|
5
|
+
readonly label: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function isChatModel(id: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* The chat models an OpenAI-compatible endpoint will accept.
|
|
10
|
+
*
|
|
11
|
+
* Sorted newest-looking first only as far as the response allows: `created` is
|
|
12
|
+
* a unix timestamp on OpenAI's own API, and absent on most compatible servers,
|
|
13
|
+
* where the order is left as the server gave it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function listOpenAiModels(baseUrl: string, apiKey: string | undefined, fetchImpl?: typeof fetch): Promise<readonly ModelOption[]>;
|
|
16
|
+
/**
|
|
17
|
+
* The models Anthropic's API will accept, newest first.
|
|
18
|
+
*
|
|
19
|
+
* Paginated with `after_id` rather than taking the first page: the response
|
|
20
|
+
* carries `has_more`, and stopping at page one would quietly hide models from
|
|
21
|
+
* the picker with nothing on screen to say so.
|
|
22
|
+
*/
|
|
23
|
+
export declare function listAnthropicModels(baseUrl: string, apiKey: string, fetchImpl?: typeof fetch): Promise<readonly ModelOption[]>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { EnvironmentError, FailureError } from '@ailoud/core';
|
|
2
|
+
/** As elsewhere in this directory: a hosted call that has not answered in a minute is not going to. */
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
4
|
+
/** Anthropic pages its model list. Bounded so a misbehaving `has_more` cannot loop forever. */
|
|
5
|
+
const MAX_PAGES = 10;
|
|
6
|
+
/**
|
|
7
|
+
* Model ids that are not chat models.
|
|
8
|
+
*
|
|
9
|
+
* OpenAI's `/v1/models` is a catalogue of everything on the account --
|
|
10
|
+
* embeddings, speech, images, moderation -- with nothing in the response that
|
|
11
|
+
* distinguishes them, so the filtering has to happen on the id. A denylist of
|
|
12
|
+
* substrings rather than an allowlist of prefixes: a new `gpt-` chat model
|
|
13
|
+
* should appear in the picker the day it ships, which an allowlist would
|
|
14
|
+
* delay until someone remembered to widen it.
|
|
15
|
+
*/
|
|
16
|
+
const NOT_CHAT = [
|
|
17
|
+
'embedding',
|
|
18
|
+
'tts',
|
|
19
|
+
'whisper',
|
|
20
|
+
'dall-e',
|
|
21
|
+
'moderation',
|
|
22
|
+
'transcribe',
|
|
23
|
+
'image',
|
|
24
|
+
'realtime',
|
|
25
|
+
'audio',
|
|
26
|
+
'stt',
|
|
27
|
+
];
|
|
28
|
+
export function isChatModel(id) {
|
|
29
|
+
const lower = id.toLowerCase();
|
|
30
|
+
return !NOT_CHAT.some((marker) => lower.includes(marker));
|
|
31
|
+
}
|
|
32
|
+
async function getJson(url, headers, fetchImpl) {
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await fetchImpl(url, { headers, signal: controller.signal });
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new EnvironmentError(controller.signal.aborted
|
|
41
|
+
? `${url} did not answer within ${REQUEST_TIMEOUT_MS / 1000} seconds.`
|
|
42
|
+
: `could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const detail = await response.text().catch(() => '');
|
|
49
|
+
throw new FailureError(`${url} returned HTTP ${response.status}${detail === '' ? '' : `: ${detail.slice(0, 300)}`}`);
|
|
50
|
+
}
|
|
51
|
+
return (await response.json());
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The chat models an OpenAI-compatible endpoint will accept.
|
|
55
|
+
*
|
|
56
|
+
* Sorted newest-looking first only as far as the response allows: `created` is
|
|
57
|
+
* a unix timestamp on OpenAI's own API, and absent on most compatible servers,
|
|
58
|
+
* where the order is left as the server gave it.
|
|
59
|
+
*/
|
|
60
|
+
export async function listOpenAiModels(baseUrl, apiKey, fetchImpl = fetch) {
|
|
61
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/models`;
|
|
62
|
+
const body = (await getJson(url, apiKey === undefined ? {} : { authorization: `Bearer ${apiKey}` }, fetchImpl));
|
|
63
|
+
const entries = (body.data ?? []).filter((entry) => typeof entry.id === 'string');
|
|
64
|
+
return entries
|
|
65
|
+
.filter((entry) => isChatModel(entry.id))
|
|
66
|
+
.sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
|
|
67
|
+
.map((entry) => ({ id: entry.id, label: entry.id }));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The models Anthropic's API will accept, newest first.
|
|
71
|
+
*
|
|
72
|
+
* Paginated with `after_id` rather than taking the first page: the response
|
|
73
|
+
* carries `has_more`, and stopping at page one would quietly hide models from
|
|
74
|
+
* the picker with nothing on screen to say so.
|
|
75
|
+
*/
|
|
76
|
+
export async function listAnthropicModels(baseUrl, apiKey, fetchImpl = fetch) {
|
|
77
|
+
const root = `${baseUrl.replace(/\/+$/, '')}/models`;
|
|
78
|
+
const headers = { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
|
|
79
|
+
const collected = [];
|
|
80
|
+
let after;
|
|
81
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
82
|
+
const url = `${root}?limit=100${after === undefined ? '' : `&after_id=${after}`}`;
|
|
83
|
+
const body = (await getJson(url, headers, fetchImpl));
|
|
84
|
+
for (const entry of body.data ?? []) {
|
|
85
|
+
if (typeof entry.id !== 'string')
|
|
86
|
+
continue;
|
|
87
|
+
collected.push({
|
|
88
|
+
id: entry.id,
|
|
89
|
+
label: typeof entry.display_name === 'string' ? entry.display_name : entry.id,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (body.has_more !== true || typeof body.last_id !== 'string')
|
|
93
|
+
break;
|
|
94
|
+
after = body.last_id;
|
|
95
|
+
}
|
|
96
|
+
return collected;
|
|
97
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Summarizer } from '@ailoud/core';
|
|
2
|
+
export interface OpenAiCompatibleOptions {
|
|
3
|
+
/** Base URL up to but not including `/chat/completions`. */
|
|
4
|
+
readonly baseUrl: string;
|
|
5
|
+
readonly model: string;
|
|
6
|
+
readonly contextTokens: number;
|
|
7
|
+
readonly maxOutputTokens: number;
|
|
8
|
+
/** Absent means the endpoint needs no key, which a local server usually does not. */
|
|
9
|
+
readonly apiKey?: string;
|
|
10
|
+
readonly fetchImpl?: typeof fetch;
|
|
11
|
+
}
|
|
12
|
+
/** The one field of the response this needs, and what to say when it is missing. */
|
|
13
|
+
export declare function extractCompletion(body: unknown): string;
|
|
14
|
+
/**
|
|
15
|
+
* Any endpoint speaking OpenAI's chat-completions shape.
|
|
16
|
+
*
|
|
17
|
+
* One adapter rather than one per vendor, because that shape is what almost
|
|
18
|
+
* everything speaks: OpenAI itself, most hosted alternatives, and -- usefully
|
|
19
|
+
* -- local servers like llama.cpp's own `llama-server`, Ollama and LM Studio.
|
|
20
|
+
* Someone who wants a bigger local model than spawning a process per request
|
|
21
|
+
* can bear points this at their own server and needs no new code here.
|
|
22
|
+
*
|
|
23
|
+
* The key is never read from the config file. It comes from the environment,
|
|
24
|
+
* because a config file is a thing people paste into issues and commit by
|
|
25
|
+
* accident, and a leaked key is not a mistake ailoud should make easy.
|
|
26
|
+
*/
|
|
27
|
+
export declare class OpenAiCompatibleSummarizer implements Summarizer {
|
|
28
|
+
private readonly options;
|
|
29
|
+
readonly name = "openai-compatible";
|
|
30
|
+
get model(): string;
|
|
31
|
+
private readonly fetchImpl;
|
|
32
|
+
constructor(options: OpenAiCompatibleOptions);
|
|
33
|
+
get contextTokens(): number;
|
|
34
|
+
complete(prompt: string): Promise<string>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { EnvironmentError, FailureError } from '@ailoud/core';
|
|
2
|
+
/**
|
|
3
|
+
* A hosted model does not get the hour a local one does. If a request has not
|
|
4
|
+
* come back in five minutes it is not coming back, and a CLI hanging on a
|
|
5
|
+
* network call is worse than one that says so.
|
|
6
|
+
*/
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 5 * 60_000;
|
|
8
|
+
/** The one field of the response this needs, and what to say when it is missing. */
|
|
9
|
+
export function extractCompletion(body) {
|
|
10
|
+
const choices = body.choices;
|
|
11
|
+
const content = choices?.[0]?.message?.content;
|
|
12
|
+
if (typeof content !== 'string' || content.trim() === '') {
|
|
13
|
+
throw new FailureError('the model returned no text. The response did not contain choices[0].message.content.');
|
|
14
|
+
}
|
|
15
|
+
return content.trim();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Any endpoint speaking OpenAI's chat-completions shape.
|
|
19
|
+
*
|
|
20
|
+
* One adapter rather than one per vendor, because that shape is what almost
|
|
21
|
+
* everything speaks: OpenAI itself, most hosted alternatives, and -- usefully
|
|
22
|
+
* -- local servers like llama.cpp's own `llama-server`, Ollama and LM Studio.
|
|
23
|
+
* Someone who wants a bigger local model than spawning a process per request
|
|
24
|
+
* can bear points this at their own server and needs no new code here.
|
|
25
|
+
*
|
|
26
|
+
* The key is never read from the config file. It comes from the environment,
|
|
27
|
+
* because a config file is a thing people paste into issues and commit by
|
|
28
|
+
* accident, and a leaked key is not a mistake ailoud should make easy.
|
|
29
|
+
*/
|
|
30
|
+
export class OpenAiCompatibleSummarizer {
|
|
31
|
+
options;
|
|
32
|
+
name = 'openai-compatible';
|
|
33
|
+
get model() {
|
|
34
|
+
return this.options.model;
|
|
35
|
+
}
|
|
36
|
+
fetchImpl;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.options = options;
|
|
39
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
40
|
+
}
|
|
41
|
+
get contextTokens() {
|
|
42
|
+
return this.options.contextTokens;
|
|
43
|
+
}
|
|
44
|
+
async complete(prompt) {
|
|
45
|
+
const url = `${this.options.baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
48
|
+
let response;
|
|
49
|
+
try {
|
|
50
|
+
response = await this.fetchImpl(url, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
headers: {
|
|
54
|
+
'content-type': 'application/json',
|
|
55
|
+
...(this.options.apiKey === undefined
|
|
56
|
+
? {}
|
|
57
|
+
: { authorization: `Bearer ${this.options.apiKey}` }),
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
model: this.options.model,
|
|
61
|
+
max_tokens: this.options.maxOutputTokens,
|
|
62
|
+
messages: [{ role: 'user', content: prompt }],
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
// An aborted request and an unreachable host both land here, and the
|
|
68
|
+
// difference matters to whoever has to fix it.
|
|
69
|
+
throw new EnvironmentError(controller.signal.aborted
|
|
70
|
+
? `${this.options.baseUrl} did not answer within ${REQUEST_TIMEOUT_MS / 60_000} minutes.`
|
|
71
|
+
: `could not reach ${this.options.baseUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
// The body usually says which of key, quota or model name is wrong,
|
|
78
|
+
// and swallowing it would leave the user guessing between them.
|
|
79
|
+
const detail = await response.text().catch(() => '');
|
|
80
|
+
throw new FailureError(`${this.options.baseUrl} returned HTTP ${response.status}${detail === '' ? '' : `: ${detail.slice(0, 400)}`}`);
|
|
81
|
+
}
|
|
82
|
+
return extractCompletion(await response.json());
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output longer than this goes through a pager, when there is a terminal to
|
|
3
|
+
* page on. Below it, paging would put a full-screen program in front of
|
|
4
|
+
* something that already fitted.
|
|
5
|
+
*/
|
|
6
|
+
export declare const PAGER_LINE_THRESHOLD = 30;
|
|
7
|
+
/**
|
|
8
|
+
* Whether this output should be paged at all.
|
|
9
|
+
*
|
|
10
|
+
* Never when stdout is not a terminal. A redirect or a pipe wants the bytes,
|
|
11
|
+
* and handing them to `less` would either hang waiting for a keypress nobody
|
|
12
|
+
* can give or write escape sequences into a file. This is the same
|
|
13
|
+
* distinction `show` already draws between the frame and the payload.
|
|
14
|
+
*
|
|
15
|
+
* Also never when PAGER is set to the empty string, which is the
|
|
16
|
+
* conventional way to say "do not page".
|
|
17
|
+
*/
|
|
18
|
+
export declare function shouldPage(text: string, isTTY: boolean, env?: NodeJS.ProcessEnv): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Shows `text` in the user's pager, resolving when they leave it.
|
|
21
|
+
*
|
|
22
|
+
* Uses the system pager rather than scrolling in-process. Up, down, page
|
|
23
|
+
* keys, search, and quitting on `q` all arrive already implemented and
|
|
24
|
+
* already behaving the way the user's other tools do -- git, man and less
|
|
25
|
+
* itself -- which is what "native" means here. A hand-rolled scroller would
|
|
26
|
+
* be a worse version of `less` that nobody had configured.
|
|
27
|
+
*
|
|
28
|
+
* Defaults to `less -R`: -R lets colour through rather than printing escape
|
|
29
|
+
* sequences literally. LESS is set only if the user has not, so their own
|
|
30
|
+
* configuration keeps winning.
|
|
31
|
+
*
|
|
32
|
+
* Falls back to writing the text out plainly if the pager cannot be started
|
|
33
|
+
* at all -- a machine without `less` should still be able to read a
|
|
34
|
+
* transcript.
|
|
35
|
+
*/
|
|
36
|
+
export declare function page(text: string, write: (chunk: string) => void, env?: NodeJS.ProcessEnv): Promise<void>;
|