@tangleai/models 0.21.1 → 0.25.0
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/CHANGELOG.md +30 -0
- package/README.md +2 -1
- package/package.json +3 -3
- package/src/check.d.ts +9 -10
- package/src/check.js +14 -18
- package/src/client.d.ts +59 -102
- package/src/client.js +307 -351
- package/src/embed.d.ts +54 -36
- package/src/embed.js +202 -287
- package/src/embedding-vector.d.ts +9 -4
- package/src/embedding-vector.js +13 -17
- package/src/errors.d.ts +26 -9
- package/src/errors.js +22 -24
- package/src/grammar.d.ts +6 -7
- package/src/grammar.js +39 -30
- package/src/index.d.ts +10 -9
- package/src/index.js +9 -10
- package/src/providers.d.ts +41 -31
- package/src/providers.js +79 -99
- package/src/replay.d.ts +52 -37
- package/src/replay.js +31 -59
- package/src/retry.d.ts +45 -86
- package/src/retry.js +50 -105
- package/src/routing.d.ts +5 -9
- package/src/routing.js +91 -79
- package/src/sse.d.ts +10 -1
- package/src/sse.js +0 -2
- package/src/structured.d.ts +19 -17
- package/src/structured.js +95 -124
package/src/errors.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
//@ts-check
|
|
2
1
|
/**
|
|
3
2
|
* The model transport error type. Every failure the package itself raises
|
|
4
3
|
* carries a stable code, like the rest of the suite:
|
|
@@ -12,34 +11,33 @@
|
|
|
12
11
|
* those come back as `{ error }` results the model can read and
|
|
13
12
|
* recover from. AiError is reserved for the transport and for misuse.
|
|
14
13
|
*
|
|
15
|
-
* Refinement (`refine.
|
|
14
|
+
* Refinement (`refine.ts`) rejects with its own `AI01xx` codes, enumerated
|
|
16
15
|
* there. They are the same idea one step further out: never thrown, they
|
|
17
16
|
* travel as records with a pointer, because their reader is a model
|
|
18
17
|
* repairing its own proposal.
|
|
19
18
|
*/
|
|
20
|
-
|
|
21
19
|
import { CodedError } from '@jarenjs/core/errors';
|
|
22
|
-
|
|
23
20
|
export class AiError extends CodedError {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
21
|
+
/**
|
|
22
|
+
* @param code - stable error code ('AI0001' | 'AI0002' | 'AI0003')
|
|
23
|
+
* @param reason - The bare reason; `message` is composed as
|
|
24
|
+
* `${code}: ${reason}` per the coded contract (transport errors
|
|
25
|
+
* have no document, so there is never a location).
|
|
26
|
+
* @param [meta] - transport metadata: the HTTP status
|
|
27
|
+
* (`0` for a network failure before any response), how many tries
|
|
28
|
+
* the client made, and the provider's `Retry-After` in ms
|
|
29
|
+
*/
|
|
30
|
+
constructor(code, reason, meta) {
|
|
31
|
+
super('AiError', code, reason, undefined, meta !== undefined && meta.cause !== undefined
|
|
32
|
+
? { cause: meta.cause }
|
|
33
|
+
: undefined);
|
|
34
|
+
if (meta !== undefined) {
|
|
35
|
+
if (meta.status !== undefined)
|
|
36
|
+
this.status = meta.status;
|
|
37
|
+
if (meta.attempts !== undefined)
|
|
38
|
+
this.attempts = meta.attempts;
|
|
39
|
+
if (meta.retryAfterMs !== undefined)
|
|
40
|
+
this.retryAfterMs = meta.retryAfterMs;
|
|
41
|
+
}
|
|
43
42
|
}
|
|
44
|
-
}
|
|
45
43
|
}
|
package/src/grammar.d.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
/** @param
|
|
2
|
-
|
|
3
|
-
* maxRepairs?: number, stream?: boolean, onAttempt?: any,
|
|
4
|
-
* selectModel?: any, limits?: any, onRoute?: any }} options */
|
|
5
|
-
export function createGrammarAuthor(options: {
|
|
1
|
+
/** @param options */
|
|
2
|
+
export declare function createGrammarAuthor(options: {
|
|
6
3
|
client: any;
|
|
7
|
-
grammar:
|
|
4
|
+
grammar: 'query' | 'jslt' | 'app' | 'fsm' | 'dag' | 'statechart' | 'workflow' | 'model';
|
|
8
5
|
profile: any;
|
|
9
6
|
schema: any;
|
|
10
7
|
refs?: any[];
|
|
@@ -17,12 +14,14 @@ export function createGrammarAuthor(options: {
|
|
|
17
14
|
limits?: any;
|
|
18
15
|
onRoute?: any;
|
|
19
16
|
}): {
|
|
20
|
-
author: (question: any, hooks?:
|
|
17
|
+
author: (question: any, hooks?: Record<string, any>) => Promise<{
|
|
21
18
|
value: any;
|
|
19
|
+
errors?: undefined;
|
|
22
20
|
raw: string;
|
|
23
21
|
attempts: number;
|
|
24
22
|
} | {
|
|
25
23
|
errors: any[];
|
|
24
|
+
value?: undefined;
|
|
26
25
|
raw: string;
|
|
27
26
|
attempts: number;
|
|
28
27
|
}>;
|
package/src/grammar.js
CHANGED
|
@@ -1,35 +1,44 @@
|
|
|
1
|
-
//@ts-check
|
|
2
1
|
/** One profile decoder and mandatory full-grammar gate for any injected compiler. */
|
|
3
2
|
import { JarenValidator } from '@jarenjs/validate';
|
|
4
|
-
import { createStructuredOutput } from
|
|
3
|
+
import { createStructuredOutput } from "./structured.js";
|
|
5
4
|
import { checkOutcome } from '@jarenjs/core/check';
|
|
6
|
-
import { createRoutedClient } from
|
|
7
|
-
|
|
8
|
-
/** @param {{ client: any, grammar: 'query'|'jslt'|'app'|'fsm'|'dag'|'statechart'|'workflow'|'model', profile: any,
|
|
9
|
-
* schema: any, refs?: any[], compile: (document: any) => any, gate?: any,
|
|
10
|
-
* maxRepairs?: number, stream?: boolean, onAttempt?: any,
|
|
11
|
-
* selectModel?: any, limits?: any, onRoute?: any }} options */
|
|
5
|
+
import { createRoutedClient } from "./routing.js";
|
|
6
|
+
/** @param options */
|
|
12
7
|
export function createGrammarAuthor(options) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
8
|
+
if (!['query', 'jslt', 'app', 'fsm', 'dag', 'statechart', 'workflow', 'model'].includes(options.grammar))
|
|
9
|
+
throw new TypeError('unknown authored grammar');
|
|
10
|
+
if (!options.profile || !options.schema || typeof options.compile !== 'function')
|
|
11
|
+
throw new TypeError('grammar author needs a derived profile, full schema and compiler');
|
|
12
|
+
const validator = new JarenValidator({ skipErrors: false, collectErrors: true });
|
|
13
|
+
for (const ref of options.refs ?? [])
|
|
14
|
+
validator.addSchema(ref);
|
|
15
|
+
const full = validator.compile(options.schema);
|
|
16
|
+
const generate = createStructuredOutput({
|
|
17
|
+
client: createRoutedClient(options, { purpose: 'author', grammar: options.grammar }), schema: options.profile,
|
|
18
|
+
name: `jaren_${options.grammar}`, strict: false, refs: options.refs,
|
|
19
|
+
maxRepairs: options.maxRepairs, stream: options.stream ?? true, onAttempt: options.onAttempt,
|
|
20
|
+
gate: [(document) => {
|
|
21
|
+
const shape = checkOutcome(full(document));
|
|
22
|
+
if (!shape.valid)
|
|
23
|
+
return shape;
|
|
24
|
+
try {
|
|
25
|
+
options.compile(document);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return {
|
|
30
|
+
valid: false, errors: [{
|
|
31
|
+
code: error.code ?? 'AI0200',
|
|
32
|
+
docPath: error.docPath ?? '', message: error.reason ?? error.message
|
|
33
|
+
}]
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}, ...[].concat(options.gate ?? [])],
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
author: (question, hooks = {}) => generate.generate([
|
|
40
|
+
{ role: 'system', content: `Author a ${options.grammar} document. Return only JSON. The full grammar and compiler validate every response.` },
|
|
41
|
+
{ role: 'user', content: question },
|
|
42
|
+
], hooks)
|
|
43
|
+
};
|
|
35
44
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
9
|
-
export { createRoutedClient, MODEL_PURPOSES } from
|
|
1
|
+
/** models: public AI mechanisms over injected Jaren foundations. */
|
|
2
|
+
export { AiError } from './errors.ts';
|
|
3
|
+
export { PROVIDERS, resolveEndpoint, probeProvider } from './providers.ts';
|
|
4
|
+
export { createSseDecoder } from './sse.ts';
|
|
5
|
+
export { createChatClient, createStreamAccumulator } from './client.ts';
|
|
6
|
+
export { createEmbeddingClient, probeEmbeddings, createHashEmbedder } from './embed.ts';
|
|
7
|
+
export { createStructuredOutput } from './structured.ts';
|
|
8
|
+
export { createGrammarAuthor } from './grammar.ts';
|
|
9
|
+
export { createRoutedClient, MODEL_PURPOSES } from './routing.ts';
|
|
10
|
+
export { invalidInput } from './check.ts';
|
package/src/index.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
//@ts-check
|
|
2
1
|
/** models: public AI mechanisms over injected Jaren foundations. */
|
|
3
|
-
export { AiError } from
|
|
4
|
-
export { PROVIDERS, resolveEndpoint, probeProvider } from
|
|
5
|
-
export { createSseDecoder } from
|
|
6
|
-
export { createChatClient, createStreamAccumulator } from
|
|
7
|
-
export { createEmbeddingClient, probeEmbeddings, createHashEmbedder } from
|
|
8
|
-
export { createStructuredOutput } from
|
|
9
|
-
export { createGrammarAuthor } from
|
|
10
|
-
export { createRoutedClient, MODEL_PURPOSES } from
|
|
11
|
-
export { invalidInput } from
|
|
2
|
+
export { AiError } from "./errors.js";
|
|
3
|
+
export { PROVIDERS, resolveEndpoint, probeProvider } from "./providers.js";
|
|
4
|
+
export { createSseDecoder } from "./sse.js";
|
|
5
|
+
export { createChatClient, createStreamAccumulator } from "./client.js";
|
|
6
|
+
export { createEmbeddingClient, probeEmbeddings, createHashEmbedder } from "./embed.js";
|
|
7
|
+
export { createStructuredOutput } from "./structured.js";
|
|
8
|
+
export { createGrammarAuthor } from "./grammar.js";
|
|
9
|
+
export { createRoutedClient, MODEL_PURPOSES } from "./routing.js";
|
|
10
|
+
export { invalidInput } from "./check.js";
|
package/src/providers.d.ts
CHANGED
|
@@ -1,13 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider endpoint resolution for OpenAI-compatible chat APIs.
|
|
3
|
+
*
|
|
4
|
+
* The bring-your-own-key reality of browser-side AI is three shapes:
|
|
5
|
+
* a cloud aggregator key (OpenRouter) or a local runtime URL (Ollama,
|
|
6
|
+
* LM Studio). All of them — and every other OpenAI-compatible server —
|
|
7
|
+
* speak the same `/chat/completions` wire format, so one small client
|
|
8
|
+
* covers the lot; the only per-provider knowledge needed is the base
|
|
9
|
+
* URL convention, which lives here.
|
|
10
|
+
*
|
|
11
|
+
* No key ever leaves the caller's hands: resolution just turns
|
|
12
|
+
* `{ provider, baseUrl, apiKey, model }` into a base, the chat URL and
|
|
13
|
+
* headers. The base is resolved ONCE and returned, so every sibling
|
|
14
|
+
* endpoint of the family (`/models`, and whatever else hangs off the
|
|
15
|
+
* same base) is composed from it — never re-derived from the chat URL
|
|
16
|
+
* by string surgery.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The built-in providers. `custom` accepts any OpenAI-compatible base
|
|
20
|
+
* URL (the caller must supply one). The local runtimes get `/v1`
|
|
21
|
+
* appended automatically when the URL carries no path — pasting
|
|
22
|
+
* `http://localhost:11434` just works.
|
|
23
|
+
*
|
|
24
|
+
* `structured` names the strongest structured-output tier the provider
|
|
25
|
+
* reliably speaks on this wire: `'json_schema'` (schema-constrained
|
|
26
|
+
* decoding), `'json'` (JSON mode without a schema), or `null` (assume
|
|
27
|
+
* nothing — the schema travels in the prompt). Either way the caller
|
|
28
|
+
* validates locally; the tier only decides how much the server helps.
|
|
29
|
+
* @type
|
|
30
|
+
*/
|
|
31
|
+
export declare const PROVIDERS: Record<string, {
|
|
32
|
+
label: string;
|
|
33
|
+
baseUrl: string | null;
|
|
34
|
+
local: boolean;
|
|
35
|
+
structured: 'json_schema' | 'json' | null;
|
|
36
|
+
}>;
|
|
1
37
|
/**
|
|
2
38
|
* Resolve a provider configuration into a concrete chat endpoint.
|
|
3
|
-
* @param
|
|
4
|
-
*
|
|
5
|
-
* @returns {{ provider: string, base: string, url: string,
|
|
6
|
-
* headers: Record<string, string>, model: string }} `base` is the
|
|
39
|
+
* @param [options]
|
|
40
|
+
* @returns `base` is the
|
|
7
41
|
* normalized base URL every endpoint of this wire family hangs off;
|
|
8
42
|
* `url` is `${base}/chat/completions`
|
|
9
43
|
*/
|
|
10
|
-
export function resolveEndpoint(options?: {
|
|
44
|
+
export declare function resolveEndpoint(options?: {
|
|
11
45
|
provider?: string;
|
|
12
46
|
baseUrl?: string;
|
|
13
47
|
apiKey?: string;
|
|
@@ -26,13 +60,9 @@ export function resolveEndpoint(options?: {
|
|
|
26
60
|
* listing (OpenRouter, Ollama and LM Studio all serve it) with the
|
|
27
61
|
* same resolved auth the chat call would use. Never throws — the
|
|
28
62
|
* result object is the settings-UI contract.
|
|
29
|
-
* @param
|
|
30
|
-
* headers?: Record<string, string>, fetch?: typeof fetch,
|
|
31
|
-
* timeoutMs?: number }} [options]
|
|
32
|
-
* @returns {Promise<{ ok: true, models: string[] } |
|
|
33
|
-
* { ok: false, status?: number, error: string }>}
|
|
63
|
+
* @param [options]
|
|
34
64
|
*/
|
|
35
|
-
export function probeProvider(options?: {
|
|
65
|
+
export declare function probeProvider(options?: {
|
|
36
66
|
provider?: string;
|
|
37
67
|
baseUrl?: string;
|
|
38
68
|
apiKey?: string;
|
|
@@ -47,23 +77,3 @@ export function probeProvider(options?: {
|
|
|
47
77
|
status?: number;
|
|
48
78
|
error: string;
|
|
49
79
|
}>;
|
|
50
|
-
/**
|
|
51
|
-
* The built-in providers. `custom` accepts any OpenAI-compatible base
|
|
52
|
-
* URL (the caller must supply one). The local runtimes get `/v1`
|
|
53
|
-
* appended automatically when the URL carries no path — pasting
|
|
54
|
-
* `http://localhost:11434` just works.
|
|
55
|
-
*
|
|
56
|
-
* `structured` names the strongest structured-output tier the provider
|
|
57
|
-
* reliably speaks on this wire: `'json_schema'` (schema-constrained
|
|
58
|
-
* decoding), `'json'` (JSON mode without a schema), or `null` (assume
|
|
59
|
-
* nothing — the schema travels in the prompt). Either way the caller
|
|
60
|
-
* validates locally; the tier only decides how much the server helps.
|
|
61
|
-
* @type {Record<string, { label: string, baseUrl: string | null,
|
|
62
|
-
* local: boolean, structured: 'json_schema' | 'json' | null }>}
|
|
63
|
-
*/
|
|
64
|
-
export const PROVIDERS: Record<string, {
|
|
65
|
-
label: string;
|
|
66
|
-
baseUrl: string | null;
|
|
67
|
-
local: boolean;
|
|
68
|
-
structured: "json_schema" | "json" | null;
|
|
69
|
-
}>;
|
package/src/providers.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
//@ts-check
|
|
2
1
|
/**
|
|
3
2
|
* Provider endpoint resolution for OpenAI-compatible chat APIs.
|
|
4
3
|
*
|
|
@@ -16,9 +15,7 @@
|
|
|
16
15
|
* same base) is composed from it — never re-derived from the chat URL
|
|
17
16
|
* by string surgery.
|
|
18
17
|
*/
|
|
19
|
-
|
|
20
|
-
import { AiError } from './errors.js';
|
|
21
|
-
|
|
18
|
+
import { AiError } from "./errors.js";
|
|
22
19
|
/**
|
|
23
20
|
* The built-in providers. `custom` accepts any OpenAI-compatible base
|
|
24
21
|
* URL (the caller must supply one). The local runtimes get `/v1`
|
|
@@ -30,100 +27,86 @@ import { AiError } from './errors.js';
|
|
|
30
27
|
* decoding), `'json'` (JSON mode without a schema), or `null` (assume
|
|
31
28
|
* nothing — the schema travels in the prompt). Either way the caller
|
|
32
29
|
* validates locally; the tier only decides how much the server helps.
|
|
33
|
-
* @type
|
|
34
|
-
* local: boolean, structured: 'json_schema' | 'json' | null }>}
|
|
30
|
+
* @type
|
|
35
31
|
*/
|
|
36
32
|
export const PROVIDERS = {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
openrouter: { label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', local: false, structured: 'json_schema' },
|
|
34
|
+
ollama: { label: 'Ollama', baseUrl: 'http://localhost:11434/v1', local: true, structured: 'json' },
|
|
35
|
+
lmstudio: { label: 'LM Studio', baseUrl: 'http://localhost:1234/v1', local: true, structured: 'json_schema' },
|
|
36
|
+
custom: { label: 'OpenAI-compatible', baseUrl: null, local: false, structured: null },
|
|
41
37
|
};
|
|
42
|
-
|
|
43
38
|
/**
|
|
44
39
|
* Resolve a provider configuration into a concrete chat endpoint.
|
|
45
|
-
* @param
|
|
46
|
-
*
|
|
47
|
-
* @returns {{ provider: string, base: string, url: string,
|
|
48
|
-
* headers: Record<string, string>, model: string }} `base` is the
|
|
40
|
+
* @param [options]
|
|
41
|
+
* @returns `base` is the
|
|
49
42
|
* normalized base URL every endpoint of this wire family hangs off;
|
|
50
43
|
* `url` is `${base}/chat/completions`
|
|
51
44
|
*/
|
|
52
45
|
export function resolveEndpoint(options = {}) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
Object.assign(headers, options.headers);
|
|
70
|
-
|
|
71
|
-
return { provider, base, url: `${base}/chat/completions`, headers, model: options.model ?? '' };
|
|
46
|
+
const provider = options.provider
|
|
47
|
+
?? (typeof options.baseUrl === 'string' && options.baseUrl.trim() !== '' ? 'custom' : 'openrouter');
|
|
48
|
+
const preset = PROVIDERS[provider];
|
|
49
|
+
if (preset === undefined)
|
|
50
|
+
throw new AiError('AI0001', `unknown provider '${provider}' (${Object.keys(PROVIDERS).join(', ')})`);
|
|
51
|
+
const configured = (options.baseUrl ?? '').trim();
|
|
52
|
+
const raw = configured === '' ? preset.baseUrl : configured;
|
|
53
|
+
if (raw === null || raw === '')
|
|
54
|
+
throw new AiError('AI0001', `provider '${provider}' needs a baseUrl`);
|
|
55
|
+
const base = normalizeBaseUrl(raw, preset.local);
|
|
56
|
+
const headers = { 'content-type': 'application/json' };
|
|
57
|
+
const apiKey = (options.apiKey ?? '').trim();
|
|
58
|
+
if (apiKey !== '')
|
|
59
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
60
|
+
Object.assign(headers, options.headers);
|
|
61
|
+
return { provider, base, url: `${base}/chat/completions`, headers, model: options.model ?? '' };
|
|
72
62
|
}
|
|
73
|
-
|
|
74
63
|
/**
|
|
75
64
|
* Probe a provider before the first turn: can this key/URL answer, and
|
|
76
65
|
* which models does it offer? GETs the OpenAI-compatible `/models`
|
|
77
66
|
* listing (OpenRouter, Ollama and LM Studio all serve it) with the
|
|
78
67
|
* same resolved auth the chat call would use. Never throws — the
|
|
79
68
|
* result object is the settings-UI contract.
|
|
80
|
-
* @param
|
|
81
|
-
* headers?: Record<string, string>, fetch?: typeof fetch,
|
|
82
|
-
* timeoutMs?: number }} [options]
|
|
83
|
-
* @returns {Promise<{ ok: true, models: string[] } |
|
|
84
|
-
* { ok: false, status?: number, error: string }>}
|
|
69
|
+
* @param [options]
|
|
85
70
|
*/
|
|
86
71
|
export async function probeProvider(options = {}) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
72
|
+
let endpoint;
|
|
73
|
+
try {
|
|
74
|
+
endpoint = resolveEndpoint(options);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
return { ok: false, error: err.message };
|
|
78
|
+
}
|
|
79
|
+
const url = `${endpoint.base}/models`;
|
|
80
|
+
const fetchFn = options.fetch ?? ((u, init) => globalThis.fetch(u, init));
|
|
81
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetchFn(url, {
|
|
86
|
+
method: 'GET',
|
|
87
|
+
headers: endpoint.headers,
|
|
88
|
+
signal: controller.signal,
|
|
89
|
+
});
|
|
90
|
+
if (response.ok !== true)
|
|
91
|
+
return { ok: false, status: response.status, error: `HTTP ${response.status} from ${url}` };
|
|
92
|
+
const payload = await response.json();
|
|
93
|
+
const models = Array.isArray(payload?.data)
|
|
94
|
+
? payload.data.map((m) => m?.id).filter((id) => typeof id === 'string')
|
|
95
|
+
: [];
|
|
96
|
+
return { ok: true, models };
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: controller.signal.aborted
|
|
102
|
+
? `no answer from ${url} within ${timeoutMs} ms`
|
|
103
|
+
: err?.message ?? String(err),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
125
109
|
}
|
|
126
|
-
|
|
127
110
|
/**
|
|
128
111
|
* Forgiving base-URL normalization, on the parts of ONE parse: trailing
|
|
129
112
|
* slashes and a pasted `/chat/completions` suffix (in any case) are
|
|
@@ -131,26 +114,23 @@ export async function probeProvider(options = {}) {
|
|
|
131
114
|
* `/v1` prefix. A query string or fragment is refused rather than
|
|
132
115
|
* carried: this wire family composes endpoints by appending a path, and
|
|
133
116
|
* `…?api-version=x/chat/completions` is a URL nobody meant.
|
|
134
|
-
* @param {string} raw
|
|
135
|
-
* @param {boolean} local
|
|
136
|
-
* @returns {string}
|
|
137
117
|
*/
|
|
138
118
|
function normalizeBaseUrl(raw, local) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
119
|
+
let url;
|
|
120
|
+
try {
|
|
121
|
+
url = new URL(raw);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new AiError('AI0001', `invalid baseUrl '${raw}'`);
|
|
125
|
+
}
|
|
126
|
+
if (url.search !== '' || url.hash !== '')
|
|
127
|
+
throw new AiError('AI0001', `baseUrl '${raw}' must not carry a query string or fragment`);
|
|
128
|
+
let path = url.pathname.replace(/\/+$/, '');
|
|
129
|
+
if (/\/chat\/completions$/i.test(path))
|
|
130
|
+
path = path.slice(0, -'/chat/completions'.length).replace(/\/+$/, '');
|
|
131
|
+
if (local && path === '')
|
|
132
|
+
path = '/v1';
|
|
133
|
+
// with no search and no hash, `href` is everything before the path —
|
|
134
|
+
// origin, plus any credentials the caller pasted — followed by the path
|
|
135
|
+
return url.href.slice(0, url.href.length - url.pathname.length) + path;
|
|
156
136
|
}
|