auto-model-router 0.12.0 → 0.13.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/.omp-plugin/marketplace.json +2 -2
- package/README.md +50 -0
- package/package.json +1 -1
- package/src/catalog/composite.ts +16 -1
- package/src/catalog/static-catalog.ts +89 -0
- package/src/catalog/types.ts +2 -2
- package/src/config/apply.ts +5 -1
- package/src/config/defaults.ts +3 -0
- package/src/config/load.ts +3 -0
- package/src/config/schema.ts +38 -0
- package/src/config/types.ts +55 -0
- package/src/config/upstreams.ts +31 -0
- package/src/cost/report.ts +23 -2
- package/src/cost/views.ts +2 -1
- package/src/lib.ts +2 -1
- package/src/server/http.ts +12 -2
- package/src/server/providers.ts +34 -1
- package/src/upstream/anthropic.ts +470 -0
- package/src/upstream/compat.ts +267 -0
- package/src/upstream/multi.ts +23 -9
- package/test/config-wizard.test.ts +1 -1
- package/test/failover.test.ts +1 -0
- package/test/turn.test.ts +1 -0
- package/test/upstreams.test.ts +378 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A named OpenAI-compatible upstream: OpenAI itself, Azure OpenAI, a vLLM or
|
|
3
|
+
* any other server speaking `/chat/completions`. Configured in
|
|
4
|
+
* `upstreams: []` with a static, priced model list (there is no universal
|
|
5
|
+
* catalog to fetch); its models appear in the catalog as `<id>/<model>` and
|
|
6
|
+
* dispatch here, exactly as `ollama/…` does.
|
|
7
|
+
*
|
|
8
|
+
* The rendered body is OpenRouter dialect, so the same rewrite Ollama needs
|
|
9
|
+
* applies: strip the slug prefix, drop `models[]` and `session_id`, turn the
|
|
10
|
+
* `reasoning` object into `reasoning_effort`, strip `cache_control` markers,
|
|
11
|
+
* and ask for usage in the final chunk. Azure differs only in the URL (the
|
|
12
|
+
* deployment name is the model) and the `api-key` header.
|
|
13
|
+
*
|
|
14
|
+
* A 429 trips a short breaker and an out-of-quota answer a longer one; the
|
|
15
|
+
* composite catalog hides the upstream's models while it is open, so a turn
|
|
16
|
+
* routes around it instead of paying a doomed dispatch first.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { RouterConfig, UpstreamEntry } from "../config/types.ts";
|
|
20
|
+
import { createLogger } from "../util/log.ts";
|
|
21
|
+
import type { StreamEvent, UpstreamChunk } from "../wire/types.ts";
|
|
22
|
+
import type { FetchLike, OllamaAvailability } from "./ollama.ts";
|
|
23
|
+
import { parseSse } from "./sse-parse.ts";
|
|
24
|
+
import { UpstreamError, type Dispatch, type DispatchOptions, type UpstreamClient, type UpstreamErrorKind } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
/** The breaker view any named upstream exposes; the Ollama one is the same shape. */
|
|
27
|
+
export type UpstreamAvailability = OllamaAvailability;
|
|
28
|
+
|
|
29
|
+
export interface NamedUpstreamClient extends UpstreamClient, UpstreamAvailability {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function asRec(v: unknown): Record<string, unknown> | null {
|
|
34
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The model id after the `<id>/` prefix, or the slug itself when it carries none. */
|
|
38
|
+
export function upstreamModelId(id: string, slug: string): string {
|
|
39
|
+
return slug.startsWith(`${id}/`) ? slug.slice(id.length + 1) : slug;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REASONING_EFFORT_MAP: Record<string, string> = {
|
|
43
|
+
minimal: "low",
|
|
44
|
+
low: "low",
|
|
45
|
+
medium: "medium",
|
|
46
|
+
high: "high",
|
|
47
|
+
xhigh: "high",
|
|
48
|
+
max: "high",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Rewrites an OpenRouter-shaped body into plain OpenAI chat-completions. Pure. */
|
|
52
|
+
export function toCompatBody(id: string, body: Record<string, unknown>): Record<string, unknown> {
|
|
53
|
+
const out: Record<string, unknown> = { ...body };
|
|
54
|
+
if (typeof out.model === "string") out.model = upstreamModelId(id, out.model);
|
|
55
|
+
delete out.models;
|
|
56
|
+
delete out.session_id;
|
|
57
|
+
delete out.stream_options;
|
|
58
|
+
const reasoning = asRec(out.reasoning);
|
|
59
|
+
delete out.reasoning;
|
|
60
|
+
delete out.reasoning_effort;
|
|
61
|
+
if (reasoning !== null && reasoning.enabled !== false && typeof reasoning.effort === "string") {
|
|
62
|
+
const mapped = REASONING_EFFORT_MAP[reasoning.effort];
|
|
63
|
+
if (mapped !== undefined) out.reasoning_effort = mapped;
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(out.messages)) {
|
|
66
|
+
out.messages = out.messages.map((m) => {
|
|
67
|
+
const msg = asRec(m);
|
|
68
|
+
if (msg === null || !Array.isArray(msg.content)) return m;
|
|
69
|
+
return {
|
|
70
|
+
...msg,
|
|
71
|
+
content: msg.content.map((part) => {
|
|
72
|
+
const p = asRec(part);
|
|
73
|
+
if (p === null || !("cache_control" in p)) return part;
|
|
74
|
+
const { cache_control: _dropped, ...rest } = p;
|
|
75
|
+
return rest;
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (out.stream === true) out.stream_options = { include_usage: true };
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** HTTP status → error kind for an OpenAI-shaped API. */
|
|
85
|
+
export function classifyCompatStatus(id: string, status: number, body: unknown): UpstreamError {
|
|
86
|
+
const rec = asRec(body);
|
|
87
|
+
const errRec = rec ? asRec(rec.error) : null;
|
|
88
|
+
const msg = errRec?.message ?? rec?.message ?? rec?.error;
|
|
89
|
+
const message = typeof msg === "string" && msg !== "" ? msg : `${id} HTTP ${status}`;
|
|
90
|
+
const code = typeof errRec?.code === "string" ? errRec.code : typeof errRec?.type === "string" ? errRec.type : "";
|
|
91
|
+
const fail = (kind: UpstreamErrorKind, retryable: boolean): UpstreamError => new UpstreamError(kind, status, message, retryable, body);
|
|
92
|
+
if (status === 401) return fail("auth", false);
|
|
93
|
+
if (status === 402) return fail("quota", true);
|
|
94
|
+
if (status === 403) return /credit|quota|plan|limit|billing/i.test(message) ? fail("quota", true) : fail("moderation", true);
|
|
95
|
+
// OpenAI reports an exhausted balance as a 429 with insufficient_quota: the account, not the moment.
|
|
96
|
+
if (status === 429) return /insufficient_quota|exceeded your current quota/i.test(`${code} ${message}`) ? fail("quota", true) : fail("rate_limit", true);
|
|
97
|
+
if (status === 404) return fail("model_unavailable", true);
|
|
98
|
+
if (status === 400 || status === 413 || status === 422) {
|
|
99
|
+
if (/context|too many tokens|token limit|maximum context|too long/i.test(message)) return fail("context_length", false);
|
|
100
|
+
return fail("invalid_request", /support|unsupported|does not|not available/i.test(message));
|
|
101
|
+
}
|
|
102
|
+
if (status >= 500) return fail("upstream_error", true);
|
|
103
|
+
return fail("upstream_error", status === 408);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function transportError(id: string, err: unknown): UpstreamError {
|
|
107
|
+
if (err instanceof UpstreamError) return err;
|
|
108
|
+
const name = err instanceof Error ? err.name : "";
|
|
109
|
+
if (name === "TimeoutError") return new UpstreamError("timeout", 0, `${id} request timed out`, true);
|
|
110
|
+
if (name === "AbortError") return new UpstreamError("aborted", 0, "request aborted", false);
|
|
111
|
+
return new UpstreamError("network", 0, err instanceof Error ? err.message : String(err), true);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The chat-completions URL and auth for an entry: Azure names the deployment in the path and keys with `api-key`. */
|
|
115
|
+
export function compatEndpoint(entry: UpstreamEntry, modelId: string): { url: string; headers: Record<string, string> } {
|
|
116
|
+
const base = entry.baseUrl.replace(/\/+$/, "");
|
|
117
|
+
const headers: Record<string, string> = { "content-type": "application/json", ...entry.headers };
|
|
118
|
+
if (entry.kind === "azure") {
|
|
119
|
+
if (entry.apiKey !== "") headers["api-key"] = entry.apiKey;
|
|
120
|
+
return { url: `${base}/openai/deployments/${encodeURIComponent(modelId)}/chat/completions?api-version=${encodeURIComponent(entry.apiVersion)}`, headers };
|
|
121
|
+
}
|
|
122
|
+
if (entry.apiKey !== "") headers.authorization = `Bearer ${entry.apiKey}`;
|
|
123
|
+
return { url: `${base}/chat/completions`, headers };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Circuit-breaker state shared by the named clients. */
|
|
127
|
+
export function createBreaker(id: string, log: ReturnType<typeof createLogger>, cooldownFor: (kind: UpstreamErrorKind) => number): UpstreamAvailability & { trip(err: UpstreamError): void } {
|
|
128
|
+
let cooldownUntil = 0;
|
|
129
|
+
let lastTrip: { kind: UpstreamErrorKind; atMs: number; message: string } | null = null;
|
|
130
|
+
return {
|
|
131
|
+
available: () => Date.now() >= cooldownUntil,
|
|
132
|
+
cooldownUntilMs: () => (Date.now() >= cooldownUntil ? null : cooldownUntil),
|
|
133
|
+
lastTrip: () => lastTrip,
|
|
134
|
+
trip(err) {
|
|
135
|
+
const ms = cooldownFor(err.kind);
|
|
136
|
+
if (ms <= 0) return;
|
|
137
|
+
cooldownUntil = Math.max(cooldownUntil, Date.now() + ms);
|
|
138
|
+
lastTrip = { kind: err.kind, atMs: Date.now(), message: err.message };
|
|
139
|
+
log.warn(`upstream ${id} unavailable; routing around it`, { kind: err.kind, cooldownMs: ms, message: err.message });
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Re-prefixes the served model so state/ledger keys match the catalog slug. */
|
|
145
|
+
export function prefixServedWith(prefix: string, chunk: UpstreamChunk): UpstreamChunk {
|
|
146
|
+
let events: StreamEvent[] | null = null;
|
|
147
|
+
for (let i = 0; i < chunk.events.length; i++) {
|
|
148
|
+
const ev = chunk.events[i];
|
|
149
|
+
if (ev !== undefined && ev.type === "start" && !ev.servedSlug.startsWith(prefix)) {
|
|
150
|
+
events ??= [...chunk.events];
|
|
151
|
+
events[i] = { ...ev, servedSlug: `${prefix}${ev.servedSlug}` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const raw = typeof chunk.raw.model === "string" && !chunk.raw.model.startsWith(prefix) ? { ...chunk.raw, model: `${prefix}${chunk.raw.model}` } : chunk.raw;
|
|
155
|
+
return events === null && raw === chunk.raw ? chunk : { raw, events: events ?? chunk.events };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The live entry for an id, read per call so a hot-reloaded list applies to the next dispatch. */
|
|
159
|
+
export function upstreamLookup(cfg: RouterConfig, id: string): () => UpstreamEntry | undefined {
|
|
160
|
+
return () => cfg.upstreams.find((u) => u.id === id);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createCompatClient(cfg: RouterConfig, id: string, fetchImpl: FetchLike = fetch): NamedUpstreamClient {
|
|
164
|
+
const lookup = upstreamLookup(cfg, id);
|
|
165
|
+
const log = createLogger(cfg.logLevel);
|
|
166
|
+
const entry = (): UpstreamEntry => {
|
|
167
|
+
const e = lookup();
|
|
168
|
+
if (e === undefined) throw new UpstreamError("model_unavailable", 0, `upstream ${id} is no longer configured`, true);
|
|
169
|
+
return e;
|
|
170
|
+
};
|
|
171
|
+
const breaker = createBreaker(id, log, (kind) => {
|
|
172
|
+
const e = lookup();
|
|
173
|
+
if (e === undefined) return 0;
|
|
174
|
+
return kind === "quota" ? e.quotaCooldownMs : kind === "rate_limit" ? e.rateLimitCooldownMs : 0;
|
|
175
|
+
});
|
|
176
|
+
const prefix = `${id}/`;
|
|
177
|
+
|
|
178
|
+
function composeSignal(e: UpstreamEntry, caller: AbortSignal | undefined): AbortSignal | null {
|
|
179
|
+
const timeout = e.timeoutMs > 0 ? AbortSignal.timeout(e.timeoutMs) : null;
|
|
180
|
+
if (caller && timeout) return AbortSignal.any([caller, timeout]);
|
|
181
|
+
return caller ?? timeout;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function httpError(res: Response): Promise<UpstreamError> {
|
|
185
|
+
let body: unknown = null;
|
|
186
|
+
try {
|
|
187
|
+
body = await res.json();
|
|
188
|
+
} catch {
|
|
189
|
+
// Status alone drives classification.
|
|
190
|
+
}
|
|
191
|
+
const err = classifyCompatStatus(id, res.status, body);
|
|
192
|
+
breaker.trip(err);
|
|
193
|
+
return err;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined): Promise<Response> {
|
|
197
|
+
const { url, headers } = compatEndpoint(e, typeof body.model === "string" ? body.model : "");
|
|
198
|
+
try {
|
|
199
|
+
return await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body), signal: composeSignal(e, signal) });
|
|
200
|
+
} catch (err) {
|
|
201
|
+
throw transportError(id, err);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
id,
|
|
207
|
+
available: breaker.available,
|
|
208
|
+
cooldownUntilMs: breaker.cooldownUntilMs,
|
|
209
|
+
lastTrip: breaker.lastTrip,
|
|
210
|
+
|
|
211
|
+
async dispatch(opts: DispatchOptions): Promise<Dispatch> {
|
|
212
|
+
const e = entry();
|
|
213
|
+
const res = await post(e, toCompatBody(id, { ...opts.body, stream: true }), opts.signal);
|
|
214
|
+
if (!res.ok) throw await httpError(res);
|
|
215
|
+
if (!res.body) throw new UpstreamError("upstream_error", res.status, "response had no body", true);
|
|
216
|
+
const parsed = parseSse(res.body, (msg, fields) => log.warn(msg, fields));
|
|
217
|
+
let resolveId!: (v: string | null) => void;
|
|
218
|
+
const idPromise = new Promise<string | null>((resolve) => {
|
|
219
|
+
resolveId = resolve;
|
|
220
|
+
});
|
|
221
|
+
let idResolved = false;
|
|
222
|
+
const resolveOnce = (v: string | null): void => {
|
|
223
|
+
if (!idResolved) {
|
|
224
|
+
idResolved = true;
|
|
225
|
+
resolveId(v);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const chunks = (async function* (): AsyncGenerator<UpstreamChunk> {
|
|
229
|
+
try {
|
|
230
|
+
for await (const chunk of parsed) {
|
|
231
|
+
const errPayload = chunk.raw.error;
|
|
232
|
+
if (errPayload !== undefined && errPayload !== null) {
|
|
233
|
+
const rec = asRec(errPayload) ?? {};
|
|
234
|
+
throw new UpstreamError("upstream_error", 0, typeof rec.message === "string" ? rec.message : `${id} stream error`, true, errPayload);
|
|
235
|
+
}
|
|
236
|
+
if (!idResolved && typeof chunk.raw.id === "string") resolveOnce(chunk.raw.id);
|
|
237
|
+
yield prefixServedWith(prefix, chunk);
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
throw transportError(id, err);
|
|
241
|
+
} finally {
|
|
242
|
+
resolveOnce(null);
|
|
243
|
+
}
|
|
244
|
+
})();
|
|
245
|
+
return { chunks, generationId: () => idPromise };
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async complete(body: Record<string, unknown>, signal: AbortSignal): Promise<{ text: string; costUsd: number | null }> {
|
|
249
|
+
const e = entry();
|
|
250
|
+
const res = await post(e, toCompatBody(id, { ...body, stream: false }), signal);
|
|
251
|
+
if (!res.ok) throw await httpError(res);
|
|
252
|
+
const json = asRec(await res.json());
|
|
253
|
+
const choices = json?.choices;
|
|
254
|
+
const choice0 = Array.isArray(choices) && choices.length > 0 ? asRec(choices[0]) : null;
|
|
255
|
+
const content = (choice0 ? asRec(choice0.message) : null)?.content;
|
|
256
|
+
return { text: typeof content === "string" ? content : "", costUsd: null };
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
// The catalog is static configuration; nothing to fetch.
|
|
260
|
+
async fetchModels(): Promise<unknown[]> {
|
|
261
|
+
return [];
|
|
262
|
+
},
|
|
263
|
+
async fetchModelsForUser(): Promise<unknown[]> {
|
|
264
|
+
return [];
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
package/src/upstream/multi.ts
CHANGED
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One `UpstreamClient` over several providers, keyed by catalog slug prefix.
|
|
3
3
|
*
|
|
4
|
-
* `ollama/…` slugs go to the Ollama client
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* `ollama/…` slugs go to the Ollama client, `<id>/…` to the named upstream
|
|
5
|
+
* with that id, everything else to OpenRouter. Catalog fetches and the
|
|
6
|
+
* adjudicator's `complete` stay on OpenRouter, whose catalog is the router's
|
|
7
|
+
* baseline; the other catalogs are read by their own sources, not through
|
|
8
|
+
* this seam.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { isOllamaSlug } from "../catalog/ollama-catalog.ts";
|
|
11
12
|
import type { Dispatch, DispatchOptions, UpstreamClient } from "./types.ts";
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
/** The named upstream a slug belongs to, by its first segment; null for OpenRouter's own. */
|
|
15
|
+
export function namedUpstreamOf(slug: string, ids: Iterable<string>): string | null {
|
|
16
|
+
const cut = slug.indexOf("/");
|
|
17
|
+
if (cut <= 0) return null;
|
|
18
|
+
const head = slug.slice(0, cut);
|
|
19
|
+
for (const id of ids) if (id === head) return id;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createMultiUpstream(openrouter: UpstreamClient, ollama: UpstreamClient, named: (id: string) => UpstreamClient | undefined = () => undefined, ids: () => Iterable<string> = () => []): UpstreamClient {
|
|
24
|
+
const pick = (model: unknown): UpstreamClient => {
|
|
25
|
+
if (typeof model !== "string") return openrouter;
|
|
26
|
+
if (isOllamaSlug(model)) return ollama;
|
|
27
|
+
const id = namedUpstreamOf(model, ids());
|
|
28
|
+
return (id === null ? undefined : named(id)) ?? openrouter;
|
|
29
|
+
};
|
|
14
30
|
return {
|
|
15
31
|
dispatch(opts: DispatchOptions): Promise<Dispatch> {
|
|
16
|
-
|
|
17
|
-
return typeof model === "string" && isOllamaSlug(model) ? ollama.dispatch(opts) : openrouter.dispatch(opts);
|
|
32
|
+
return pick(opts.body.model).dispatch(opts);
|
|
18
33
|
},
|
|
19
34
|
complete(body, signal) {
|
|
20
|
-
|
|
21
|
-
return typeof model === "string" && isOllamaSlug(model) ? ollama.complete(body, signal) : openrouter.complete(body, signal);
|
|
35
|
+
return pick(body.model).complete(body, signal);
|
|
22
36
|
},
|
|
23
37
|
fetchModels: (signal) => openrouter.fetchModels(signal),
|
|
24
38
|
fetchModelsForUser: (signal) => openrouter.fetchModelsForUser(signal),
|
|
@@ -142,7 +142,7 @@ describe("validateField", () => {
|
|
|
142
142
|
|
|
143
143
|
describe("WIZARD_SECTIONS coverage", () => {
|
|
144
144
|
/** Leaves that are edited as whole records/arrays rather than fields. */
|
|
145
|
-
const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "digest.toolAliases", "profiles"]);
|
|
145
|
+
const RECORD_PATHS = new Set(["ollama.prices", "ollama.twins", "digest.toolAliases", "profiles", "upstreams"]);
|
|
146
146
|
|
|
147
147
|
function leaves(obj: unknown, prefix = ""): string[] {
|
|
148
148
|
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return [prefix];
|
package/test/failover.test.ts
CHANGED
|
@@ -32,6 +32,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
32
32
|
server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
|
|
33
33
|
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
|
|
34
34
|
ollama: { enabled: false, baseUrl: "http://127.0.0.1:11434/v1", apiKey: "", timeoutMs: 30_000, catalogTtlMs: 300_000, includeLocal: false, prices: {}, twins: {}, costBias: 1, biasUntilUsage: 0.9, usagePollMs: 0, quotaCooldownMs: 0, rateLimitCooldownMs: 0, planCreditsUsd: 0 },
|
|
35
|
+
upstreams: [],
|
|
35
36
|
benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
|
|
36
37
|
tiers: {
|
|
37
38
|
trivial: { minQuality: 0, maxInputPerMtok: 0.3, qualityExponent: 0, pin: [] },
|
package/test/turn.test.ts
CHANGED
|
@@ -32,6 +32,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
32
32
|
server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24, subagentProfile: "auto-sub" },
|
|
33
33
|
openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
|
|
34
34
|
ollama: { enabled: false, baseUrl: "http://127.0.0.1:11434/v1", apiKey: "", timeoutMs: 30_000, catalogTtlMs: 300_000, includeLocal: false, prices: {}, twins: {}, costBias: 1, biasUntilUsage: 0.9, usagePollMs: 0, quotaCooldownMs: 0, rateLimitCooldownMs: 0, planCreditsUsd: 0 },
|
|
35
|
+
upstreams: [],
|
|
35
36
|
benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
|
|
36
37
|
tiers: {
|
|
37
38
|
trivial: { minQuality: 0, maxInputPerMtok: 0.3, qualityExponent: 0, pin: [] },
|