@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/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.js`) rejects with its own `AI01xx` codes, enumerated
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
- * @param {string} code - stable error code ('AI0001' | 'AI0002' | 'AI0003')
26
- * @param {string} reason - The bare reason; `message` is composed as
27
- * `${code}: ${reason}` per the coded contract (transport errors
28
- * have no document, so there is never a location).
29
- * @param {{ status?: number, attempts?: number, retryAfterMs?: number,
30
- * cause?: unknown }} [meta] - transport metadata: the HTTP status
31
- * (`0` for a network failure before any response), how many tries
32
- * the client made, and the provider's `Retry-After` in ms
33
- */
34
- constructor(code, reason, meta) {
35
- super('AiError', code, reason, undefined,
36
- meta !== undefined && meta.cause !== undefined
37
- ? { cause: meta.cause }
38
- : undefined);
39
- if (meta !== undefined) {
40
- if (meta.status !== undefined) this.status = meta.status;
41
- if (meta.attempts !== undefined) this.attempts = meta.attempts;
42
- if (meta.retryAfterMs !== undefined) this.retryAfterMs = meta.retryAfterMs;
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 {{ client: any, grammar: 'query'|'jslt'|'app'|'fsm'|'dag'|'statechart'|'workflow'|'model', profile: any,
2
- * schema: any, refs?: any[], compile: (document: any) => any, gate?: any,
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: "query" | "jslt" | "app" | "fsm" | "dag" | "statechart" | "workflow" | "model";
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?: {}) => Promise<{
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 './structured.js';
3
+ import { createStructuredOutput } from "./structured.js";
5
4
  import { checkOutcome } from '@jarenjs/core/check';
6
- import { createRoutedClient } from './routing.js';
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
- if (!['query', 'jslt', 'app', 'fsm', 'dag', 'statechart', 'workflow', 'model'].includes(options.grammar))
14
- throw new TypeError('unknown authored grammar');
15
- if (!options.profile || !options.schema || typeof options.compile !== 'function')
16
- throw new TypeError('grammar author needs a derived profile, full schema and compiler');
17
- const validator = new JarenValidator({ skipErrors: false, collectErrors: true });
18
- for (const ref of options.refs ?? []) validator.addSchema(ref);
19
- const full = validator.compile(options.schema);
20
- const generate = createStructuredOutput({ client: createRoutedClient(options, { purpose: 'author', grammar: options.grammar }), schema: options.profile,
21
- name: `jaren_${options.grammar}`, strict: false, refs: options.refs,
22
- maxRepairs: options.maxRepairs, stream: options.stream ?? true, onAttempt: options.onAttempt,
23
- gate: [(document) => {
24
- const shape = checkOutcome(full(document));
25
- if (!shape.valid) return shape;
26
- try { options.compile(document); return true; }
27
- catch (error) { return { valid: false, errors: [{ code: error.code ?? 'AI0200',
28
- docPath: error.docPath ?? '', message: error.reason ?? error.message }] }; }
29
- }, ...[].concat(options.gate ?? [])],
30
- });
31
- return { author: (question, hooks = {}) => generate.generate([
32
- { role: 'system', content: `Author a ${options.grammar} document. Return only JSON. The full grammar and compiler validate every response.` },
33
- { role: 'user', content: question },
34
- ], hooks) };
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
- export { AiError } from "./errors.js";
2
- export { createSseDecoder } from "./sse.js";
3
- export { createStructuredOutput } from "./structured.js";
4
- export { createGrammarAuthor } from "./grammar.js";
5
- export { invalidInput } from "./check.js";
6
- export { PROVIDERS, resolveEndpoint, probeProvider } from "./providers.js";
7
- export { createChatClient, createStreamAccumulator } from "./client.js";
8
- export { createEmbeddingClient, probeEmbeddings, createHashEmbedder } from "./embed.js";
9
- export { createRoutedClient, MODEL_PURPOSES } from "./routing.js";
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 './errors.js';
4
- export { PROVIDERS, resolveEndpoint, probeProvider } from './providers.js';
5
- export { createSseDecoder } from './sse.js';
6
- export { createChatClient, createStreamAccumulator } from './client.js';
7
- export { createEmbeddingClient, probeEmbeddings, createHashEmbedder } from './embed.js';
8
- export { createStructuredOutput } from './structured.js';
9
- export { createGrammarAuthor } from './grammar.js';
10
- export { createRoutedClient, MODEL_PURPOSES } from './routing.js';
11
- export { invalidInput } from './check.js';
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";
@@ -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 {{ provider?: string, baseUrl?: string, apiKey?: string,
4
- * model?: string, headers?: Record<string, string> }} [options]
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 {{ provider?: string, baseUrl?: string, apiKey?: string,
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 {Record<string, { label: string, baseUrl: string | null,
34
- * local: boolean, structured: 'json_schema' | 'json' | null }>}
30
+ * @type
35
31
  */
36
32
  export const PROVIDERS = {
37
- openrouter: { label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', local: false, structured: 'json_schema' },
38
- ollama: { label: 'Ollama', baseUrl: 'http://localhost:11434/v1', local: true, structured: 'json' },
39
- lmstudio: { label: 'LM Studio', baseUrl: 'http://localhost:1234/v1', local: true, structured: 'json_schema' },
40
- custom: { label: 'OpenAI-compatible', baseUrl: null, local: false, structured: null },
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 {{ provider?: string, baseUrl?: string, apiKey?: string,
46
- * model?: string, headers?: Record<string, string> }} [options]
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
- const provider = options.provider
54
- ?? (typeof options.baseUrl === 'string' && options.baseUrl.trim() !== '' ? 'custom' : 'openrouter');
55
- const preset = PROVIDERS[provider];
56
- if (preset === undefined)
57
- throw new AiError('AI0001', `unknown provider '${provider}' (${Object.keys(PROVIDERS).join(', ')})`);
58
-
59
- const configured = (options.baseUrl ?? '').trim();
60
- const raw = configured === '' ? preset.baseUrl : configured;
61
- if (raw === null || raw === '')
62
- throw new AiError('AI0001', `provider '${provider}' needs a baseUrl`);
63
-
64
- const base = normalizeBaseUrl(raw, preset.local);
65
- /** @type {Record<string, string>} */
66
- const headers = { 'content-type': 'application/json' };
67
- const apiKey = (options.apiKey ?? '').trim();
68
- if (apiKey !== '') headers.authorization = `Bearer ${apiKey}`;
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 {{ provider?: string, baseUrl?: string, apiKey?: string,
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
- /** @type {ReturnType<typeof resolveEndpoint>} */
88
- let endpoint;
89
- try {
90
- endpoint = resolveEndpoint(options);
91
- }
92
- catch (err) {
93
- return { ok: false, error: /** @type {Error} */ (err).message };
94
- }
95
- const url = `${endpoint.base}/models`;
96
- const fetchFn = options.fetch ?? ((u, init) => globalThis.fetch(u, init));
97
- const timeoutMs = options.timeoutMs ?? 5000;
98
- const controller = new AbortController();
99
- const timer = setTimeout(() => controller.abort(), timeoutMs);
100
- try {
101
- const response = await fetchFn(url, {
102
- method: 'GET',
103
- headers: endpoint.headers,
104
- signal: controller.signal,
105
- });
106
- if (response.ok !== true)
107
- return { ok: false, status: response.status, error: `HTTP ${response.status} from ${url}` };
108
- const payload = await response.json();
109
- const models = Array.isArray(payload?.data)
110
- ? payload.data.map((m) => m?.id).filter((id) => typeof id === 'string')
111
- : [];
112
- return { ok: true, models };
113
- }
114
- catch (err) {
115
- return {
116
- ok: false,
117
- error: controller.signal.aborted
118
- ? `no answer from ${url} within ${timeoutMs} ms`
119
- : /** @type {any} */ (err)?.message ?? String(err),
120
- };
121
- }
122
- finally {
123
- clearTimeout(timer);
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
- /** @type {URL} */
140
- let url;
141
- try {
142
- url = new URL(raw);
143
- }
144
- catch {
145
- throw new AiError('AI0001', `invalid baseUrl '${raw}'`);
146
- }
147
- if (url.search !== '' || url.hash !== '')
148
- throw new AiError('AI0001', `baseUrl '${raw}' must not carry a query string or fragment`);
149
- let path = url.pathname.replace(/\/+$/, '');
150
- if (/\/chat\/completions$/i.test(path))
151
- path = path.slice(0, -'/chat/completions'.length).replace(/\/+$/, '');
152
- if (local && path === '') path = '/v1';
153
- // with no search and no hash, `href` is everything before the path —
154
- // origin, plus any credentials the caller pasted — followed by the path
155
- return url.href.slice(0, url.href.length - url.pathname.length) + path;
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
  }