auto-model-router 0.23.0 → 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/.omp-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +14 -3
- package/src/catalog/static-catalog.ts +11 -3
- package/src/config/schema.ts +4 -2
- package/src/config/types.ts +18 -4
- package/src/config/upstreams.ts +2 -0
- package/src/router/candidates.ts +21 -11
- package/src/server/providers.ts +2 -2
- package/src/upstream/anthropic.ts +28 -2
- package/test/tier-plan.test.ts +16 -0
- package/test/upstreams.test.ts +43 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.25.0",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.25.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/package.json
CHANGED
package/src/catalog/composite.ts
CHANGED
|
@@ -35,7 +35,12 @@ export interface CompositeBias {
|
|
|
35
35
|
/** False when OpenRouter cannot dispatch (no key): its models are listed for metadata only, never served. Default true. */
|
|
36
36
|
serveOpenRouter?: () => boolean;
|
|
37
37
|
/** Named upstreams' models, built from the OpenRouter models (twins) and filtered by each upstream's breaker. */
|
|
38
|
-
named?: {
|
|
38
|
+
named?: {
|
|
39
|
+
models(openrouter: readonly CatalogModel[]): readonly CatalogModel[];
|
|
40
|
+
serving(id: string): boolean;
|
|
41
|
+
/** That upstream's `costBias`, so prepaid capacity ranks below list price. 1 when it has none. */
|
|
42
|
+
bias?(id: string): number;
|
|
43
|
+
};
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/** Shared empty list, so a deployment without named upstreams keeps the merged snapshot's identity. */
|
|
@@ -81,8 +86,14 @@ export function createCompositeCatalog(
|
|
|
81
86
|
merged = serveBase ? mergeSnapshots(base, available ? models : []) : { ...base, models: available ? [...models] : [] };
|
|
82
87
|
if (named.length > 0) merged = { ...merged, models: [...merged.models, ...named] };
|
|
83
88
|
// A fresh object either way once anything changed; stamp the live bias so
|
|
84
|
-
// candidate scoring reads it off the snapshot it is ranking.
|
|
85
|
-
|
|
89
|
+
// candidate scoring reads it off the snapshot it is ranking. Each named upstream
|
|
90
|
+
// adds its own, so prepaid capacity ranks below list price without being free.
|
|
91
|
+
const biases: Record<string, number> = { ollama: providerBias };
|
|
92
|
+
for (const m of named) {
|
|
93
|
+
const b = bias.named?.bias?.(m.provider) ?? 1;
|
|
94
|
+
if (b !== 1) biases[m.provider] = b;
|
|
95
|
+
}
|
|
96
|
+
merged = { ...merged, providerBias: biases };
|
|
86
97
|
return merged;
|
|
87
98
|
}
|
|
88
99
|
|
|
@@ -29,9 +29,17 @@ export function buildUpstreamModels(entry: UpstreamEntry, openrouter: readonly C
|
|
|
29
29
|
const twin = (m.twin !== undefined ? bySlug.get(m.twin) : undefined) ?? twins.get(normalizeModelKey(m.id)) ?? null;
|
|
30
30
|
const modalities: Modality[] = ["text"];
|
|
31
31
|
if (m.vision ?? twin?.inputModalities.includes("image") ?? false) modalities.push("image");
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// An omitted price means "whatever the twin costs": a subscription sells the same
|
|
33
|
+
// weights per token on OpenRouter, so the twin is the only honest figure. Falling
|
|
34
|
+
// back to zero instead would win every tier outright and silence the quality floors.
|
|
35
|
+
const price: CatalogModel["price"] = {
|
|
36
|
+
prompt: m.input !== undefined ? m.input / 1e6 : (twin?.price.prompt ?? 0),
|
|
37
|
+
completion: m.output !== undefined ? m.output / 1e6 : (twin?.price.completion ?? 0),
|
|
38
|
+
};
|
|
39
|
+
const cacheRead = m.cachedInput !== undefined ? m.cachedInput / 1e6 : twin?.price.cacheRead;
|
|
40
|
+
const cacheWrite = m.cacheWrite !== undefined ? m.cacheWrite / 1e6 : twin?.price.cacheWrite;
|
|
41
|
+
if (cacheRead !== undefined) price.cacheRead = cacheRead;
|
|
42
|
+
if (cacheWrite !== undefined) price.cacheWrite = cacheWrite;
|
|
35
43
|
const model: CatalogModel = {
|
|
36
44
|
slug: `${entry.id}/${m.id}`,
|
|
37
45
|
canonicalSlug: `${entry.id}/${m.id}`,
|
package/src/config/schema.ts
CHANGED
|
@@ -64,8 +64,8 @@ export const RESERVED_UPSTREAM_IDS: readonly string[] = ["openrouter", "ollama",
|
|
|
64
64
|
const upstreamModel = z.strictObject({
|
|
65
65
|
id: z.string().min(1),
|
|
66
66
|
name: z.string().optional(),
|
|
67
|
-
input: z.number().nonnegative(),
|
|
68
|
-
output: z.number().nonnegative(),
|
|
67
|
+
input: z.number().nonnegative().optional(),
|
|
68
|
+
output: z.number().nonnegative().optional(),
|
|
69
69
|
cachedInput: z.number().nonnegative().optional(),
|
|
70
70
|
cacheWrite: z.number().nonnegative().optional(),
|
|
71
71
|
contextLength: z.number().int().positive().optional(),
|
|
@@ -87,11 +87,13 @@ const upstream = z.strictObject({
|
|
|
87
87
|
enabled: z.boolean().optional(),
|
|
88
88
|
baseUrl: z.string().min(1),
|
|
89
89
|
apiKey: z.string().optional(),
|
|
90
|
+
auth: z.enum(["api-key", "oauth-bearer"]).optional(),
|
|
90
91
|
apiVersion: z.string().optional(),
|
|
91
92
|
headers: z.record(z.string(), z.string()).optional(),
|
|
92
93
|
timeoutMs: z.number().positive().optional(),
|
|
93
94
|
rateLimitCooldownMs: z.number().nonnegative().optional(),
|
|
94
95
|
quotaCooldownMs: z.number().nonnegative().optional(),
|
|
96
|
+
costBias: z.number().positive().max(1).optional(),
|
|
95
97
|
models: z.array(upstreamModel),
|
|
96
98
|
});
|
|
97
99
|
|
package/src/config/types.ts
CHANGED
|
@@ -906,10 +906,15 @@ export interface UpstreamModelConfig {
|
|
|
906
906
|
/** The model id the provider knows (an Azure deployment name for `azure`). The catalog slug is `<upstream id>/<id>`. */
|
|
907
907
|
id: string;
|
|
908
908
|
name?: string;
|
|
909
|
-
/**
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
909
|
+
/**
|
|
910
|
+
* USD per million prompt tokens. Absent ⇒ the OpenRouter twin's price, which is what
|
|
911
|
+
* a subscription upstream wants: the same weights are sold per token there, so the
|
|
912
|
+
* twin is the only honest figure for what a turn is worth. Pair it with `costBias`
|
|
913
|
+
* to rank prepaid capacity below list without pretending it is free.
|
|
914
|
+
*/
|
|
915
|
+
input?: number;
|
|
916
|
+
/** USD per million completion tokens. Absent ⇒ the OpenRouter twin's. */
|
|
917
|
+
output?: number;
|
|
913
918
|
/** USD per million cached prompt tokens, when the provider discounts them. */
|
|
914
919
|
cachedInput?: number;
|
|
915
920
|
/** USD per million prompt tokens written to cache (Anthropic). */
|
|
@@ -943,11 +948,20 @@ export interface UpstreamEntry {
|
|
|
943
948
|
apiKey: string;
|
|
944
949
|
/** Azure only: the `api-version` query parameter. */
|
|
945
950
|
apiVersion: string;
|
|
951
|
+
/** `api-key` sends `x-api-key`; `oauth-bearer` sends `Authorization: Bearer` plus the Claude OAuth beta headers (a Claude Pro/Max subscription token). */
|
|
952
|
+
auth: "api-key" | "oauth-bearer";
|
|
946
953
|
/** Extra request headers, e.g. a gateway's own auth. */
|
|
947
954
|
headers: Record<string, string>;
|
|
948
955
|
timeoutMs: number;
|
|
949
956
|
rateLimitCooldownMs: number;
|
|
950
957
|
quotaCooldownMs: number;
|
|
958
|
+
/**
|
|
959
|
+
* Ranking multiplier on this upstream's forecast cost, like `ollama.costBias`: below 1
|
|
960
|
+
* prefers capacity that is already paid for (a Claude Pro/Max subscription) without
|
|
961
|
+
* pricing it at zero, which would win every tier and make quality floors meaningless.
|
|
962
|
+
* Ranking only — the ledger still records the price the catalog carries. Default 1.
|
|
963
|
+
*/
|
|
964
|
+
costBias: number;
|
|
951
965
|
models: UpstreamModelConfig[];
|
|
952
966
|
}
|
|
953
967
|
|
package/src/config/upstreams.ts
CHANGED
|
@@ -17,9 +17,11 @@ export function completeUpstreamEntry(raw: Record<string, unknown>): UpstreamEnt
|
|
|
17
17
|
apiKey: typeof raw.apiKey === "string" ? raw.apiKey : "",
|
|
18
18
|
apiVersion: typeof raw.apiVersion === "string" ? raw.apiVersion : "2024-10-21",
|
|
19
19
|
headers: (raw.headers as Record<string, string> | undefined) ?? {},
|
|
20
|
+
auth: raw.auth === "oauth-bearer" ? "oauth-bearer" : "api-key",
|
|
20
21
|
timeoutMs: typeof raw.timeoutMs === "number" ? raw.timeoutMs : 600_000,
|
|
21
22
|
rateLimitCooldownMs: typeof raw.rateLimitCooldownMs === "number" ? raw.rateLimitCooldownMs : 60_000,
|
|
22
23
|
quotaCooldownMs: typeof raw.quotaCooldownMs === "number" ? raw.quotaCooldownMs : 15 * 60_000,
|
|
24
|
+
costBias: typeof raw.costBias === "number" && raw.costBias > 0 ? raw.costBias : 1,
|
|
23
25
|
models: (raw.models as UpstreamEntry["models"] | undefined) ?? [],
|
|
24
26
|
};
|
|
25
27
|
}
|
package/src/router/candidates.ts
CHANGED
|
@@ -254,20 +254,33 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
254
254
|
// Price ceilings at the ACTUAL prompt size: long-context overrides can
|
|
255
255
|
// push a model over the ceiling exactly when conversations get long.
|
|
256
256
|
// Catalog prices are per-token; ceilings are per million tokens.
|
|
257
|
+
//
|
|
258
|
+
// The ceiling is compared against the BIASED price, because that is what the turn
|
|
259
|
+
// actually costs this deployment: capacity already paid for — an Ollama plan's
|
|
260
|
+
// included credits, a Claude Pro/Max subscription — carries the list price of the
|
|
261
|
+
// twin it is priced from, and a subscription model at $2-5/Mtok list would be
|
|
262
|
+
// thrown out here on every cheap tier before `costBias` was ever consulted. That
|
|
263
|
+
// made the bias silently inert: setting it to 0.00001 changed no decision at all.
|
|
257
264
|
const price = priceAt(model, Math.max(1, features.promptTokens));
|
|
258
|
-
|
|
265
|
+
const providerBias =
|
|
266
|
+
snapshot.providerBias?.[model.provider] ??
|
|
267
|
+
(model.provider === "ollama" ? cfg.ollama.costBias : (cfg.upstreams.find((u) => u.id === model.provider)?.costBias ?? 1));
|
|
268
|
+
const biasedPrompt = price.prompt * providerBias;
|
|
269
|
+
const biasedCompletion = price.completion * providerBias;
|
|
270
|
+
const biasNote = providerBias === 1 ? "" : ` (×${providerBias} bias on $${(price.prompt * 1e6).toFixed(2)} list)`;
|
|
271
|
+
if (!relaxPrice && priceCeiling !== undefined && biasedPrompt * 1e6 > priceCeiling) {
|
|
259
272
|
rejected.push({
|
|
260
273
|
slug,
|
|
261
274
|
reason: "over_price_ceiling",
|
|
262
|
-
detail: `input $${(
|
|
275
|
+
detail: `input $${(biasedPrompt * 1e6).toFixed(2)}/Mtok > ceiling $${priceCeiling.toFixed(2)}${biasNote}`,
|
|
263
276
|
});
|
|
264
277
|
continue;
|
|
265
278
|
}
|
|
266
|
-
if (!relaxPrice && tierCfg.maxOutputPerMtok !== undefined &&
|
|
279
|
+
if (!relaxPrice && tierCfg.maxOutputPerMtok !== undefined && biasedCompletion * 1e6 > tierCfg.maxOutputPerMtok) {
|
|
267
280
|
rejected.push({
|
|
268
281
|
slug,
|
|
269
282
|
reason: "over_price_ceiling",
|
|
270
|
-
detail: `output $${(
|
|
283
|
+
detail: `output $${(biasedCompletion * 1e6).toFixed(2)}/Mtok > ceiling $${tierCfg.maxOutputPerMtok}${biasNote}`,
|
|
271
284
|
});
|
|
272
285
|
continue;
|
|
273
286
|
}
|
|
@@ -357,12 +370,9 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
357
370
|
filters.escalationCostWeight > 0 && args.escalationUsdPerPromptToken !== undefined
|
|
358
371
|
? filters.escalationCostWeight * escalationRate * args.escalationUsdPerPromptToken * features.promptTokens
|
|
359
372
|
: 0;
|
|
360
|
-
//
|
|
361
|
-
// spent, so
|
|
362
|
-
// ledger still records list price.
|
|
363
|
-
// The snapshot carries the LIVE bias (credit-aware); the static config
|
|
364
|
-
// value is the fallback for snapshots built without one.
|
|
365
|
-
const providerBias = snapshot.providerBias?.[model.provider] ?? (model.provider === "ollama" ? cfg.ollama.costBias : 1);
|
|
373
|
+
// `providerBias` is computed with the price ceilings above — capacity already paid for
|
|
374
|
+
// is money already spent, so it is valued below list in ranking AND against the
|
|
375
|
+
// ceilings. The ledger still records list price either way.
|
|
366
376
|
const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5) + escalationUsd) * latencyMult * providerBias;
|
|
367
377
|
// Score is assigned in a SECOND PASS below: both qualityNormalization and
|
|
368
378
|
// capabilityFloorUsd are properties of the candidate SET, not of one
|
|
@@ -381,7 +391,7 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
381
391
|
if (escalationUsd > 0) {
|
|
382
392
|
reasons.push(`escalation risk +$${escalationUsd.toFixed(6)} (rate ${(escalationRate * 100).toFixed(2)}% × measured retry cost)`);
|
|
383
393
|
}
|
|
384
|
-
if (providerBias !== 1) reasons.push(`provider bias ×${providerBias} (ollama.costBias, plan credits remaining)`);
|
|
394
|
+
if (providerBias !== 1) reasons.push(`provider bias ×${providerBias} (${model.provider === "ollama" ? "ollama.costBias, plan credits remaining" : `${model.provider}.costBias, capacity already paid for`})`);
|
|
385
395
|
if (latencyMult > 1 && latency !== null) {
|
|
386
396
|
reasons.push(
|
|
387
397
|
`latency penalty ×${latencyMult.toFixed(2)} (ttft ${Math.round(latency.ttftMs)}ms, ${latency.tokensPerSec.toFixed(0)} tok/s over ${latency.samples} samples)`,
|
package/src/server/providers.ts
CHANGED
|
@@ -77,7 +77,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
77
77
|
};
|
|
78
78
|
const namedServingOne = (id: string): boolean => {
|
|
79
79
|
const entry = cfg.upstreams.find((u) => u.id === id);
|
|
80
|
-
if (entry === undefined || !entry.enabled || entry.apiKey === "" && entry.kind !== "openai") return entry !== undefined && entry.enabled && (named(id)?.available() ?? false);
|
|
80
|
+
if (entry === undefined || !entry.enabled || (entry.apiKey === "" && entry.kind !== "openai" && entry.auth !== "oauth-bearer")) return entry !== undefined && entry.enabled && (named(id)?.available() ?? false);
|
|
81
81
|
return named(id)?.available() ?? false;
|
|
82
82
|
};
|
|
83
83
|
const namedServing = (): string[] => cfg.upstreams.filter((u) => u.enabled && namedServingOne(u.id)).map((u) => u.id);
|
|
@@ -91,7 +91,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
91
91
|
usage: ollamaUsage,
|
|
92
92
|
live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
|
|
93
93
|
serveOpenRouter: () => cfg.openrouter.apiKey !== "",
|
|
94
|
-
named: { models: (base) => staticCatalog.get(base), serving: namedServingOne },
|
|
94
|
+
named: { models: (base) => staticCatalog.get(base), serving: namedServingOne, bias: (id) => cfg.upstreams.find((u) => u.id === id)?.costBias ?? 1 },
|
|
95
95
|
}),
|
|
96
96
|
ollama,
|
|
97
97
|
ollamaServing,
|
|
@@ -355,6 +355,27 @@ function transportError(id: string, err: unknown): UpstreamError {
|
|
|
355
355
|
return new UpstreamError("network", 0, err instanceof Error ? err.message : String(err), true);
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Anthropic accepts a Pro/Max subscription token only on Claude Code's own traffic: unless
|
|
360
|
+
* the FIRST system block carries this exact line, the API answers 429 `rate_limit_error`
|
|
361
|
+
* with the message "Error" — a refusal wearing a quota's clothes, not a real rate limit,
|
|
362
|
+
* which the breaker would otherwise read as "slow down" and cool the upstream off.
|
|
363
|
+
*
|
|
364
|
+
* Claude Code sends it itself. Every other client — an OpenAI-wire caller, the router's own
|
|
365
|
+
* classifier and digest chores — would be refused, so an `oauth-bearer` upstream adds it
|
|
366
|
+
* when it is missing. It goes in as its own leading block rather than being merged into the
|
|
367
|
+
* caller's text, because that is the shape Claude Code sends and it keeps the caller's
|
|
368
|
+
* `cache_control` markers attached to the blocks they were written for.
|
|
369
|
+
*/
|
|
370
|
+
const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
371
|
+
|
|
372
|
+
function withClaudeCodeIdentity(body: Record<string, unknown>): Record<string, unknown> {
|
|
373
|
+
const system = Array.isArray(body.system) ? (body.system as Block[]) : [];
|
|
374
|
+
const first = asRec(system[0]);
|
|
375
|
+
if (first !== null && typeof first.text === "string" && first.text.startsWith(CLAUDE_CODE_IDENTITY)) return body;
|
|
376
|
+
return { ...body, system: [{ type: "text", text: CLAUDE_CODE_IDENTITY }, ...system] };
|
|
377
|
+
}
|
|
378
|
+
|
|
358
379
|
export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl: FetchLike = fetch): NamedUpstreamClient {
|
|
359
380
|
const lookup = upstreamLookup(cfg, id);
|
|
360
381
|
const log = createLogger(cfg.logLevel);
|
|
@@ -379,7 +400,8 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
|
|
|
379
400
|
const { modelId, model } = modelInfo(e, slug);
|
|
380
401
|
const opts: AnthropicBodyOptions = { modelId, supportsReasoning: model?.supportsReasoning ?? false };
|
|
381
402
|
if (model?.maxCompletionTokens !== undefined) opts.maxCompletionTokens = model.maxCompletionTokens;
|
|
382
|
-
|
|
403
|
+
const rendered = toAnthropicBody(body, opts);
|
|
404
|
+
return { rendered: e.auth === "oauth-bearer" ? withClaudeCodeIdentity(rendered) : rendered, servedSlug: `${id}/${modelId}` };
|
|
383
405
|
}
|
|
384
406
|
function composeSignal(e: UpstreamEntry, caller: AbortSignal | undefined): AbortSignal | null {
|
|
385
407
|
const timeout = e.timeoutMs > 0 ? AbortSignal.timeout(e.timeoutMs) : null;
|
|
@@ -388,7 +410,11 @@ export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl:
|
|
|
388
410
|
}
|
|
389
411
|
async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined): Promise<Response> {
|
|
390
412
|
const headers: Record<string, string> = { "content-type": "application/json", "anthropic-version": ANTHROPIC_VERSION, ...e.headers };
|
|
391
|
-
if (e.
|
|
413
|
+
if (e.auth === "oauth-bearer") {
|
|
414
|
+
// A Claude Pro/Max subscription token: Bearer auth at the first-party API, with the OAuth beta. No per-token cost is reported.
|
|
415
|
+
headers["authorization"] = `Bearer ${e.apiKey}`;
|
|
416
|
+
headers["anthropic-beta"] = e.headers["anthropic-beta"] ?? "oauth-2025-04-20,claude-code-20250219";
|
|
417
|
+
} else if (e.apiKey !== "") headers["x-api-key"] = e.apiKey;
|
|
392
418
|
try {
|
|
393
419
|
return await fetchImpl(`${e.baseUrl.replace(/\/+$/, "")}/v1/messages`, { method: "POST", headers, body: JSON.stringify(body), signal: composeSignal(e, signal) });
|
|
394
420
|
} catch (err) {
|
package/test/tier-plan.test.ts
CHANGED
|
@@ -358,6 +358,22 @@ describe("adaptive price ceilings", () => {
|
|
|
358
358
|
expect(on.candidates.map((c) => c.model.slug)).not.toContain("a/4");
|
|
359
359
|
expect(on.rejected.some((r) => r.slug === "a/4" && r.reason === "over_price_ceiling")).toBe(true);
|
|
360
360
|
});
|
|
361
|
+
|
|
362
|
+
test("a price ceiling judges the BIASED price, so prepaid capacity is not thrown out on list", () => {
|
|
363
|
+
// A subscription upstream inherits its OpenRouter twin's list price ($4/Mtok here) and is
|
|
364
|
+
// discounted by `costBias` because the capacity is already paid for. Judging the ceiling on
|
|
365
|
+
// list threw it out before the bias was ever read, which made the bias entirely inert.
|
|
366
|
+
const snap = snapshot(priced);
|
|
367
|
+
const biased = { ...snap, providerBias: { [snap.models[0]!.provider]: 0.1 } };
|
|
368
|
+
const run = (s: typeof snap) =>
|
|
369
|
+
buildCandidates({ req, features, tier: "moderate", task: "coding", snapshot: s, ledger: null, cfg: { ...BASE, adaptivePriceCeilings: true }, expectedCompletionTokens: 512, warmSlug: null });
|
|
370
|
+
// Unbiased: the band tightens moderate to $3 and a/4 is over it.
|
|
371
|
+
expect(run(snap).rejected.some((r) => r.slug === "a/4" && r.reason === "over_price_ceiling")).toBe(true);
|
|
372
|
+
// Biased ×0.1: $4 list is $0.40 to this deployment, so it clears the same ceiling.
|
|
373
|
+
const on = run(biased);
|
|
374
|
+
expect(on.candidates.map((c) => c.model.slug)).toContain("a/4");
|
|
375
|
+
expect(on.rejected.some((r) => r.slug === "a/4")).toBe(false);
|
|
376
|
+
});
|
|
361
377
|
});
|
|
362
378
|
|
|
363
379
|
describe("quality normalization and capability floor (benchmark findings 4/6)", () => {
|
package/test/upstreams.test.ts
CHANGED
|
@@ -100,6 +100,23 @@ describe("static catalog", () => {
|
|
|
100
100
|
expect(unknown!.tokenizer).toBe("Claude");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
test("a subscription model with no price of its own inherits the twin's, so it never ranks as free", () => {
|
|
104
|
+
// A Pro/Max subscription publishes no per-token rates, and pricing it at zero would beat
|
|
105
|
+
// every model in every tier outright. The twin sells the same weights, so it is the rate.
|
|
106
|
+
const e = entry({ id: "anthropic-subscription", kind: "anthropic", costBias: 0.1, models: [{ id: "claude-sonnet-4-20250514", twin: "anthropic/claude-sonnet-4" }] });
|
|
107
|
+
const [sub] = buildUpstreamModels(e, twins);
|
|
108
|
+
const twin = twins.find((m) => m.slug === "anthropic/claude-sonnet-4")!;
|
|
109
|
+
expect(sub!.price.prompt).toBe(twin.price.prompt);
|
|
110
|
+
expect(sub!.price.completion).toBe(twin.price.completion);
|
|
111
|
+
expect(sub!.price.prompt).toBeGreaterThan(0);
|
|
112
|
+
expect(sub!.isFree).toBe(false);
|
|
113
|
+
// The discount is a RANKING bias on the entry, never a rewrite of the recorded price.
|
|
114
|
+
expect(e.costBias).toBe(0.1);
|
|
115
|
+
// An explicit zero still means zero: a self-hosted server is genuinely free.
|
|
116
|
+
const free = entry({ id: "vllm", kind: "openai", models: [{ id: "local", input: 0, output: 0 }] });
|
|
117
|
+
expect(buildUpstreamModels(free, twins)[0]!.price).toEqual({ prompt: 0, completion: 0 });
|
|
118
|
+
});
|
|
119
|
+
|
|
103
120
|
test("the source rebuilds only when the entries or the OpenRouter models change", () => {
|
|
104
121
|
const cfg = cfgWith([entry({ id: "vllm", kind: "openai" })]);
|
|
105
122
|
const src = createStaticCatalogSource(cfg);
|
|
@@ -349,6 +366,32 @@ describe("the Anthropic client", () => {
|
|
|
349
366
|
expect(classifyAnthropicStatus("a", 400, { error: { message: "prompt is too long: 250000 tokens" } })).toMatchObject({ kind: "context_length", retryable: false });
|
|
350
367
|
expect(classifyAnthropicStatus("a", 401, {})).toMatchObject({ kind: "auth", retryable: false });
|
|
351
368
|
});
|
|
369
|
+
|
|
370
|
+
test("a subscription upstream: OAuth bearer instead of x-api-key, and the Claude Code identity leads the system blocks", async () => {
|
|
371
|
+
// Anthropic answers a Pro/Max token 429 "Error" unless the first system block says this,
|
|
372
|
+
// so the literal is the wire contract and is spelled out here rather than imported.
|
|
373
|
+
const identity = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
374
|
+
let headers: Record<string, string> = {};
|
|
375
|
+
let sent: Record<string, unknown> = {};
|
|
376
|
+
const fetchImpl = async (_u: string, init?: RequestInit): Promise<Response> => {
|
|
377
|
+
headers = init?.headers as Record<string, string>;
|
|
378
|
+
sent = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
379
|
+
return sse([`event: message_stop${NL}data: {"type":"message_stop"}`]);
|
|
380
|
+
};
|
|
381
|
+
const cfg = cfgWith([entry({ id: "sub", kind: "anthropic", auth: "oauth-bearer", baseUrl: "https://api.anthropic.com", apiKey: "sk-ant-oat01-x", models: [{ id: "claude-sonnet-5", input: 0, output: 0 }] })]);
|
|
382
|
+
const drain = async (messages: unknown[]): Promise<void> => {
|
|
383
|
+
const d = await createAnthropicClient(cfg, "sub", fetchImpl).dispatch({ body: { model: "sub/claude-sonnet-5", messages, max_tokens: 16 }, sessionId: "s", signal: new AbortController().signal });
|
|
384
|
+
for await (const c of d.chunks) void c;
|
|
385
|
+
};
|
|
386
|
+
await drain([{ role: "user", content: "ping" }]);
|
|
387
|
+
expect(headers.authorization).toBe("Bearer sk-ant-oat01-x");
|
|
388
|
+
expect(headers["anthropic-beta"]).toContain("oauth-2025-04-20");
|
|
389
|
+
expect(headers["x-api-key"]).toBeUndefined();
|
|
390
|
+
expect(sent.system).toEqual([{ type: "text", text: identity }]);
|
|
391
|
+
// A caller that already identifies as Claude Code keeps its own block; it is not said twice.
|
|
392
|
+
await drain([{ role: "system", content: `${identity} Be brief.` }, { role: "user", content: "ping" }]);
|
|
393
|
+
expect(sent.system).toEqual([{ type: "text", text: `${identity} Be brief.` }]);
|
|
394
|
+
});
|
|
352
395
|
});
|
|
353
396
|
|
|
354
397
|
describe("the composite catalog with named upstreams", () => {
|