auto-model-router 0.16.0 → 0.18.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/.claude/skills/agentdox/SKILL.md +13 -3
- package/.mcp.json +1 -1
- package/.omp-plugin/marketplace.json +2 -2
- package/CLAUDE.md +2 -2
- package/README.md +32 -1
- package/docs/AGENTDOX-BRIDGE.md +18 -1
- package/omp-extension/embed-logic.ts +91 -1
- package/omp-extension/remote-logic.ts +4 -1
- package/omp-extension/router-embed.ts +16 -4
- package/package.json +1 -1
- package/src/catalog/composite.ts +12 -1
- package/src/cli/config-cmd.ts +5 -2
- package/src/cli/connect.ts +6 -2
- package/src/context/scope.ts +63 -0
- package/src/lib.ts +1 -0
- package/src/router/candidates.ts +31 -16
- package/src/server/advise.ts +1 -0
- package/src/server/catalog-view.ts +130 -0
- package/src/server/digest.ts +1 -0
- package/src/server/http.ts +38 -0
- package/src/server/providers.ts +2 -2
- package/src/wire/openai/request.ts +7 -1
- package/src/wire/types.ts +9 -0
- package/test/anthropic-wire.test.ts +6 -0
- package/test/catalog-view.test.ts +249 -0
- package/test/embed-logic.test.ts +86 -1
- package/test/escalate.test.ts +1 -0
- package/test/failover.test.ts +1 -0
- package/test/models-yml.test.ts +10 -0
- package/test/remote.test.ts +8 -1
- package/test/scope.test.ts +54 -1
- package/test/turn.test.ts +1 -0
- package/tools/replay.ts +1 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The router's model catalog as data, with a policy's verdict per model: what
|
|
3
|
+
* `GET /v1/router/catalog` answers. A team front door renders it for its
|
|
4
|
+
* governance views ("which models can this group reach, and why not the
|
|
5
|
+
* rest"), so admission is decided by the SAME matcher and in the SAME order
|
|
6
|
+
* as `buildCandidates` — the built-in denials, then allow, deny, free and
|
|
7
|
+
* tool support — over the filters `applyRequestPolicy` produced, and a pin
|
|
8
|
+
* takes effect only when the pinned model itself survives them, exactly as
|
|
9
|
+
* `select` treats a forced slug. Tiers are per turn and take no part.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { CatalogModel, Modality, QualityScores } from "../catalog/types.ts";
|
|
13
|
+
import type { FilterConfig } from "../config/types.ts";
|
|
14
|
+
import { builtInDenial, globToRe } from "../router/candidates.ts";
|
|
15
|
+
|
|
16
|
+
export interface CatalogViewModel {
|
|
17
|
+
slug: string;
|
|
18
|
+
canonicalSlug: string;
|
|
19
|
+
name: string;
|
|
20
|
+
/** The upstream that serves it: `openrouter`, `ollama`, or a named upstream's id. */
|
|
21
|
+
provider: string;
|
|
22
|
+
/**
|
|
23
|
+
* The slug's namespace before the first `/` (`anthropic`). For a named
|
|
24
|
+
* upstream's `<id>/<model>` the vendor is the model id's own namespace when
|
|
25
|
+
* it carries one (`vllm/meta-llama/x` ⇒ `meta-llama`), else the upstream id.
|
|
26
|
+
*/
|
|
27
|
+
vendor: string;
|
|
28
|
+
contextLength: number;
|
|
29
|
+
maxCompletionTokens?: number;
|
|
30
|
+
supportsTools: boolean;
|
|
31
|
+
supportsReasoning: boolean;
|
|
32
|
+
reasoningMandatory: boolean;
|
|
33
|
+
inputModalities: Modality[];
|
|
34
|
+
/** USD per MILLION tokens, the catalog's own units. */
|
|
35
|
+
price: { prompt: number; completion: number; cacheRead?: number; cacheWrite?: number };
|
|
36
|
+
quality: QualityScores;
|
|
37
|
+
isFree: boolean;
|
|
38
|
+
/** Present only when a policy was asked about. */
|
|
39
|
+
admitted?: boolean;
|
|
40
|
+
/** Present only when not admitted: which filter kept the model out. */
|
|
41
|
+
reason?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CatalogView {
|
|
45
|
+
/** When the catalog was last fetched; 0 before the first fetch. */
|
|
46
|
+
fetchedAtMs: number;
|
|
47
|
+
/** Sorted by slug. */
|
|
48
|
+
models: CatalogViewModel[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The filters a turn routes under: the configured ones, or those `applyRequestPolicy` merged a policy into. */
|
|
52
|
+
export type AdmissionFilters = Pick<FilterConfig, "allow" | "deny" | "includeFree" | "requireToolSupport">;
|
|
53
|
+
|
|
54
|
+
export interface CatalogViewArgs {
|
|
55
|
+
models: readonly CatalogModel[];
|
|
56
|
+
fetchedAtMs: number;
|
|
57
|
+
/** When given, every model carries `admitted` and, if out, `reason`. */
|
|
58
|
+
verdict?: { filters: AdmissionFilters; pin?: string };
|
|
59
|
+
/** Why a provider cannot take a turn now, or null when it can. */
|
|
60
|
+
unserved: (provider: string) => string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Per-token catalog prices as USD per million, rounded so `0.22` does not come out as `0.22000000000000003`. */
|
|
64
|
+
function perMillion(usdPerToken: number): number {
|
|
65
|
+
return Math.round(usdPerToken * 1e6 * 1e6) / 1e6;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function vendorOf(model: Pick<CatalogModel, "slug" | "provider">): string {
|
|
69
|
+
const slash = model.slug.indexOf("/");
|
|
70
|
+
const head = slash === -1 ? model.slug : model.slug.slice(0, slash);
|
|
71
|
+
if (model.provider === "openrouter" || model.provider === "ollama" || head !== model.provider) return head;
|
|
72
|
+
const rest = model.slug.slice(slash + 1);
|
|
73
|
+
const inner = rest.indexOf("/");
|
|
74
|
+
return inner === -1 ? model.provider : rest.slice(0, inner);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Why the filters keep a model out, or null when it passes. The order is
|
|
79
|
+
* `buildCandidates`' so the first reason is the one a turn would record.
|
|
80
|
+
*/
|
|
81
|
+
function filterReason(model: CatalogModel, filters: AdmissionFilters, allowRes: readonly RegExp[], denyRes: readonly RegExp[]): string | null {
|
|
82
|
+
const builtIn = builtInDenial(model);
|
|
83
|
+
if (builtIn !== null) return builtIn;
|
|
84
|
+
if (allowRes.length > 0 && !allowRes.some((re) => re.test(model.slug))) return "not in the allow list";
|
|
85
|
+
const denied = denyRes.findIndex((re) => re.test(model.slug));
|
|
86
|
+
if (denied !== -1) return `denied by ${filters.deny[denied]}`;
|
|
87
|
+
if (model.isFree && !filters.includeFree) return "free models excluded (filters.includeFree)";
|
|
88
|
+
if (filters.requireToolSupport && !model.supportsTools) return "no tool support (filters.requireToolSupport)";
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function catalogView(args: CatalogViewArgs): CatalogView {
|
|
93
|
+
const sorted = [...args.models].sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
|
|
94
|
+
const verdict = args.verdict;
|
|
95
|
+
const allowRes = verdict === undefined ? [] : verdict.filters.allow.map(globToRe);
|
|
96
|
+
const denyRes = verdict === undefined ? [] : verdict.filters.deny.map(globToRe);
|
|
97
|
+
// One reason per slug before the pin is considered: the pin only bites when
|
|
98
|
+
// the pinned model itself is in, as `select` ignores a pin the filters drop.
|
|
99
|
+
const reasons = new Map<string, string | null>();
|
|
100
|
+
if (verdict !== undefined) for (const m of sorted) reasons.set(m.slug, args.unserved(m.provider) ?? filterReason(m, verdict.filters, allowRes, denyRes));
|
|
101
|
+
const pin = verdict?.pin !== undefined && reasons.get(verdict.pin) === null ? verdict.pin : undefined;
|
|
102
|
+
const models = sorted.map((m): CatalogViewModel => {
|
|
103
|
+
const price: CatalogViewModel["price"] = { prompt: perMillion(m.price.prompt), completion: perMillion(m.price.completion) };
|
|
104
|
+
if (m.price.cacheRead !== undefined) price.cacheRead = perMillion(m.price.cacheRead);
|
|
105
|
+
if (m.price.cacheWrite !== undefined) price.cacheWrite = perMillion(m.price.cacheWrite);
|
|
106
|
+
const out: CatalogViewModel = {
|
|
107
|
+
slug: m.slug,
|
|
108
|
+
canonicalSlug: m.canonicalSlug,
|
|
109
|
+
name: m.name,
|
|
110
|
+
provider: m.provider,
|
|
111
|
+
vendor: vendorOf(m),
|
|
112
|
+
contextLength: m.contextLength,
|
|
113
|
+
...(m.maxCompletionTokens === undefined ? {} : { maxCompletionTokens: m.maxCompletionTokens }),
|
|
114
|
+
supportsTools: m.supportsTools,
|
|
115
|
+
supportsReasoning: m.supportsReasoning,
|
|
116
|
+
reasoningMandatory: m.reasoningMandatory,
|
|
117
|
+
inputModalities: [...m.inputModalities],
|
|
118
|
+
price,
|
|
119
|
+
quality: { ...m.quality },
|
|
120
|
+
isFree: m.isFree,
|
|
121
|
+
};
|
|
122
|
+
if (verdict !== undefined) {
|
|
123
|
+
const reason = reasons.get(m.slug) ?? (pin !== undefined && m.slug !== pin ? `pinned to ${pin}` : null);
|
|
124
|
+
out.admitted = reason === null;
|
|
125
|
+
if (reason !== null) out.reason = reason;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
});
|
|
129
|
+
return { fetchedAtMs: args.fetchedAtMs, models };
|
|
130
|
+
}
|
package/src/server/digest.ts
CHANGED
package/src/server/http.ts
CHANGED
|
@@ -6,6 +6,10 @@ import { createBridgeFromConfig } from "../context/index.ts";
|
|
|
6
6
|
import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
|
|
7
7
|
import { createLedger } from "../cost/ledger.ts";
|
|
8
8
|
import { createSessionOverrides } from "./overrides.ts";
|
|
9
|
+
import { catalogView } from "./catalog-view.ts";
|
|
10
|
+
import { buildUpstreamModels } from "../catalog/static-catalog.ts";
|
|
11
|
+
import { applyRequestPolicy, resolveProfile } from "../router/index.ts";
|
|
12
|
+
import { parsePolicyHeader } from "../wire/openai/request.ts";
|
|
9
13
|
import { createDigester } from "./digest.ts";
|
|
10
14
|
import { advise } from "./advise.ts";
|
|
11
15
|
import { TIER_ORDER, type Tier } from "../router/types.ts";
|
|
@@ -484,6 +488,40 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
484
488
|
if (req.method === "GET" && url.pathname === "/v1/router/stats") {
|
|
485
489
|
return json(computeStats(ledger));
|
|
486
490
|
}
|
|
491
|
+
if (req.method === "GET" && url.pathname === "/v1/router/catalog") {
|
|
492
|
+
// The catalog as data, judged under `?policy=` (the X-Omp-Policy
|
|
493
|
+
// JSON) when one is given: a front door's governance view. Never
|
|
494
|
+
// blocks on a fetch; before the first one it is empty.
|
|
495
|
+
const rawPolicy = url.searchParams.get("policy");
|
|
496
|
+
let verdict: { filters: RouterConfig["filters"]; pin?: string } | undefined;
|
|
497
|
+
if (rawPolicy !== null) {
|
|
498
|
+
// The header parser forgives a malformed value (a turn must not
|
|
499
|
+
// fail on it); a view asked about one must say so instead.
|
|
500
|
+
let parsed: unknown;
|
|
501
|
+
try {
|
|
502
|
+
parsed = JSON.parse(rawPolicy);
|
|
503
|
+
} catch {
|
|
504
|
+
parsed = undefined;
|
|
505
|
+
}
|
|
506
|
+
if (parsed === undefined || parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
507
|
+
return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "policy must be a JSON object (the X-Omp-Policy shape)" });
|
|
508
|
+
}
|
|
509
|
+
const policed = applyRequestPolicy(resolveProfile(cfg, "auto"), cfg, parsePolicyHeader(rawPolicy), undefined);
|
|
510
|
+
verdict = { filters: policed.cfg.filters, ...(policed.forceSlug === undefined ? {} : { pin: policed.forceSlug }) };
|
|
511
|
+
}
|
|
512
|
+
const snap = catalog.peekAll?.() ?? catalog.peek();
|
|
513
|
+
// A disabled named upstream's models are not built for routing; list them too, so "why not" has an answer.
|
|
514
|
+
const disabled = snap === null ? [] : cfg.upstreams.filter((u) => !u.enabled).flatMap((u) => buildUpstreamModels(u, snap.models));
|
|
515
|
+
const unserved = (provider: string): string | null => {
|
|
516
|
+
if (provider === "openrouter") return cfg.openrouter.apiKey === "" ? "upstream openrouter has no API key" : null;
|
|
517
|
+
if (provider === "ollama") return !cfg.ollama.enabled ? "upstream ollama is disabled" : ollama.available() ? null : "upstream ollama is in cooldown";
|
|
518
|
+
const entry = cfg.upstreams.find((u) => u.id === provider);
|
|
519
|
+
if (entry === undefined) return `upstream ${provider} is not configured`;
|
|
520
|
+
if (!entry.enabled) return `upstream ${provider} is disabled`;
|
|
521
|
+
return providers.named(provider)?.available() ?? true ? null : `upstream ${provider} is in cooldown`;
|
|
522
|
+
};
|
|
523
|
+
return json(catalogView({ models: snap === null ? [] : [...snap.models, ...disabled], fetchedAtMs: snap?.fetchedAtMs ?? 0, ...(verdict === undefined ? {} : { verdict }), unserved }));
|
|
524
|
+
}
|
|
487
525
|
if (req.method === "GET" && url.pathname === "/v1/router/spend") {
|
|
488
526
|
// Spend since an instant over a harness set: what a front door's
|
|
489
527
|
// budget check needs when it cannot read the ledger file.
|
package/src/server/providers.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { createAnthropicClient } from "../upstream/anthropic.ts";
|
|
|
11
11
|
import { createCompatClient, type NamedUpstreamClient } from "../upstream/compat.ts";
|
|
12
12
|
import { createOllamaCatalog } from "../catalog/ollama-catalog.ts";
|
|
13
13
|
import { createCatalog } from "../catalog/openrouter-catalog.ts";
|
|
14
|
-
import type { CatalogSource } from "../catalog/types.ts";
|
|
14
|
+
import type { CatalogSnapshot, CatalogSource } from "../catalog/types.ts";
|
|
15
15
|
import type { RouterConfig } from "../config/types.ts";
|
|
16
16
|
import { createMultiUpstream } from "../upstream/multi.ts";
|
|
17
17
|
import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
|
|
@@ -24,7 +24,7 @@ import { createLogger, type Logger } from "../util/log.ts";
|
|
|
24
24
|
|
|
25
25
|
export interface Providers {
|
|
26
26
|
upstream: UpstreamClient;
|
|
27
|
-
catalog: CatalogSource & { ollamaModels?(): unknown[]; ollamaBias?(): number };
|
|
27
|
+
catalog: CatalogSource & { ollamaModels?(): unknown[]; ollamaBias?(): number; peekAll?(): CatalogSnapshot | null };
|
|
28
28
|
/** Always present: it carries the circuit breaker. Whether it SERVES follows `cfg.ollama.enabled`. */
|
|
29
29
|
ollama: OllamaClient;
|
|
30
30
|
/** True while Ollama Cloud is enabled and out of cooldown, read live. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { acceptScope } from "../../context/scope.ts";
|
|
1
|
+
import { acceptOrigin, acceptScope } from "../../context/scope.ts";
|
|
2
2
|
import type {
|
|
3
3
|
RequestPolicy,
|
|
4
4
|
CompactionEdit,
|
|
@@ -341,6 +341,11 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
341
341
|
const agentdoxGroup = acceptScope(headers.get("x-agentdox-group"));
|
|
342
342
|
const agentdoxPersonal = acceptScope(headers.get("x-agentdox-personal"));
|
|
343
343
|
|
|
344
|
+
// The workspace's repository fingerprint, for a front door that keeps a
|
|
345
|
+
// project registry. Same sentinel rule as the scope: omp sends the env-var
|
|
346
|
+
// NAME when the variable is unset, and a name is never a fingerprint.
|
|
347
|
+
const agentdoxOrigin = acceptOrigin(headers.get("x-agentdox-origin"));
|
|
348
|
+
|
|
344
349
|
// Subagent marker from the embed extension (sessions without a UI).
|
|
345
350
|
const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
|
|
346
351
|
|
|
@@ -409,6 +414,7 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
409
414
|
agentdoxScope,
|
|
410
415
|
agentdoxGroup,
|
|
411
416
|
agentdoxPersonal,
|
|
417
|
+
agentdoxOrigin,
|
|
412
418
|
isSubagent,
|
|
413
419
|
...(policy === undefined ? {} : { policy }),
|
|
414
420
|
requestedModel,
|
package/src/wire/types.ts
CHANGED
|
@@ -109,6 +109,15 @@ export interface NormRequest {
|
|
|
109
109
|
* renders LAST. Empty when absent or not a slug ⇒ no personal layer.
|
|
110
110
|
*/
|
|
111
111
|
agentdoxPersonal: string;
|
|
112
|
+
/**
|
|
113
|
+
* The workspace's repository fingerprint from the `X-Agentdox-Origin`
|
|
114
|
+
* request header (`<host>/<path>` of its git remote `origin`, see
|
|
115
|
+
* `src/context/scope.ts`). Validated here, in one place, for a front door
|
|
116
|
+
* with a project registry — the team edition — which uses it to find the
|
|
117
|
+
* project two same-named folders are really about. The router's own bridge
|
|
118
|
+
* has no registry and never reads it. Empty when absent or not a fingerprint.
|
|
119
|
+
*/
|
|
120
|
+
agentdoxOrigin: string;
|
|
112
121
|
/** `X-Omp-Subagent: 1`: the caller is an omp subagent (a session without a UI). */
|
|
113
122
|
isSubagent: boolean;
|
|
114
123
|
/**
|
|
@@ -154,6 +154,12 @@ describe("parseMessagesRequest", () => {
|
|
|
154
154
|
expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-group": "Not A Slug" })).agentdoxGroup).toBe("");
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
test("the origin fingerprint reaches the request on the Anthropic path too", () => {
|
|
158
|
+
expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "user-agent": "claude-cli/2.1.263" })).agentdoxOrigin).toBe("");
|
|
159
|
+
expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-origin": "github.com/drewappling/omp-router" })).agentdoxOrigin).toBe("github.com/drewappling/omp-router");
|
|
160
|
+
expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-origin": "https://github.com/a/b" })).agentdoxOrigin).toBe("");
|
|
161
|
+
});
|
|
162
|
+
|
|
157
163
|
test("a request captured from Claude Code 2.1: system inside messages, JSON user_id, adaptive thinking with effort, 23 custom tools", () => {
|
|
158
164
|
const fixture = JSON.parse(readFileSync("test/fixtures/harness/claude-code.json", "utf8")) as { headers: Record<string, string>; body: Record<string, unknown> };
|
|
159
165
|
const norm = parseMessagesRequest(fixture.body, new Headers(fixture.headers));
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
|
|
7
|
+
import { buildUpstreamModels } from "../src/catalog/static-catalog.ts";
|
|
8
|
+
import type { CatalogModel } from "../src/catalog/types.ts";
|
|
9
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
10
|
+
import { completeUpstreamEntry } from "../src/config/upstreams.ts";
|
|
11
|
+
import { catalogView, vendorOf, type CatalogView, type CatalogViewModel } from "../src/server/catalog-view.ts";
|
|
12
|
+
import { startServer, type StartedServer } from "../src/server/http.ts";
|
|
13
|
+
import { openDb } from "../src/util/sqlite.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The catalog as data for a front door's governance views: every model the
|
|
17
|
+
* router knows, and under a policy whether a turn could reach it and why not.
|
|
18
|
+
* Pinned over the pure view and over `GET /v1/router/catalog`, which judges
|
|
19
|
+
* with the router's own matcher — the same globs, the same order as
|
|
20
|
+
* `buildCandidates`, a pin that only bites when the pinned model is in.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const FIXTURE = (await Bun.file("test/fixtures/openrouter-models.json").json()) as { data: unknown[] };
|
|
24
|
+
|
|
25
|
+
function model(over: Partial<CatalogModel> & { slug: string }): CatalogModel {
|
|
26
|
+
return {
|
|
27
|
+
provider: "openrouter",
|
|
28
|
+
canonicalSlug: `${over.slug}-20260101`,
|
|
29
|
+
name: over.slug,
|
|
30
|
+
contextLength: 200_000,
|
|
31
|
+
supportsTools: true,
|
|
32
|
+
supportsReasoning: false,
|
|
33
|
+
reasoningMandatory: false,
|
|
34
|
+
supportsToolChoice: true,
|
|
35
|
+
inputModalities: ["text"],
|
|
36
|
+
price: { prompt: 0.000003, completion: 0.000015 },
|
|
37
|
+
priceTiers: [],
|
|
38
|
+
quality: { intelligence: 60 },
|
|
39
|
+
tokenizer: "Claude",
|
|
40
|
+
isFree: false,
|
|
41
|
+
createdAtMs: 0,
|
|
42
|
+
author: over.slug.split("/")[0] ?? "",
|
|
43
|
+
...over,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const vllm = completeUpstreamEntry({ id: "vllm", kind: "openai", baseUrl: "http://vllm:8000/v1", apiKey: "", models: [{ id: "meta-llama/Llama-3", input: 0, output: 0, contextLength: 8_000 }] });
|
|
48
|
+
const azure = completeUpstreamEntry({ id: "azure-eu", kind: "azure", enabled: false, baseUrl: "https://r.openai.azure.com", apiKey: "k", models: [{ id: "gpt-4o-deploy", input: 2.5, output: 10, cachedInput: 1.25 }] });
|
|
49
|
+
|
|
50
|
+
const MODELS: CatalogModel[] = [
|
|
51
|
+
model({ slug: "openai/gpt-5", quality: { intelligence: 70, coding: 72 }, maxCompletionTokens: 128_000, price: { prompt: 0.00000125, completion: 0.00001, cacheRead: 0.000000125 } }),
|
|
52
|
+
model({ slug: "anthropic/claude-sonnet-5", supportsReasoning: true }),
|
|
53
|
+
model({ slug: "anthropic/claude-haiku-5" }),
|
|
54
|
+
model({ slug: "anthropic/claude-opus-5:batch" }),
|
|
55
|
+
model({ slug: "liquid/lfm:free", isFree: true, price: { prompt: 0, completion: 0 } }),
|
|
56
|
+
model({ slug: "tencent/translator", supportsTools: false }),
|
|
57
|
+
model({ slug: "ollama/glm-5.3-flash", provider: "ollama", author: "ollama" }),
|
|
58
|
+
...buildUpstreamModels(vllm, []),
|
|
59
|
+
...buildUpstreamModels(azure, []),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const served = () => null;
|
|
63
|
+
const bySlug = (view: CatalogView): Map<string, CatalogViewModel> => new Map(view.models.map((m) => [m.slug, m]));
|
|
64
|
+
|
|
65
|
+
describe("catalogView", () => {
|
|
66
|
+
test("sorted by slug, prices per million, vendor per slug, and no verdict without a policy", () => {
|
|
67
|
+
const view = catalogView({ models: MODELS, fetchedAtMs: 123, unserved: served });
|
|
68
|
+
expect(view.fetchedAtMs).toBe(123);
|
|
69
|
+
expect(view.models.map((m) => m.slug)).toEqual([...MODELS.map((m) => m.slug)].sort());
|
|
70
|
+
const gpt = bySlug(view).get("openai/gpt-5")!;
|
|
71
|
+
expect(gpt).toEqual({
|
|
72
|
+
slug: "openai/gpt-5",
|
|
73
|
+
canonicalSlug: "openai/gpt-5-20260101",
|
|
74
|
+
name: "openai/gpt-5",
|
|
75
|
+
provider: "openrouter",
|
|
76
|
+
vendor: "openai",
|
|
77
|
+
contextLength: 200_000,
|
|
78
|
+
maxCompletionTokens: 128_000,
|
|
79
|
+
supportsTools: true,
|
|
80
|
+
supportsReasoning: false,
|
|
81
|
+
reasoningMandatory: false,
|
|
82
|
+
inputModalities: ["text"],
|
|
83
|
+
price: { prompt: 1.25, completion: 10, cacheRead: 0.125 },
|
|
84
|
+
quality: { intelligence: 70, coding: 72 },
|
|
85
|
+
isFree: false,
|
|
86
|
+
});
|
|
87
|
+
expect("admitted" in gpt).toBe(false);
|
|
88
|
+
expect("reason" in gpt).toBe(false);
|
|
89
|
+
expect("maxCompletionTokens" in bySlug(view).get("anthropic/claude-haiku-5")!).toBe(false);
|
|
90
|
+
// A named upstream's prices are per million already; they round-trip.
|
|
91
|
+
expect(bySlug(view).get("azure-eu/gpt-4o-deploy")!.price).toEqual({ prompt: 2.5, completion: 10, cacheRead: 1.25 });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("vendor: the namespace before the first slash; a named upstream's model id may carry its own", () => {
|
|
95
|
+
expect(vendorOf({ slug: "anthropic/claude-sonnet-5", provider: "openrouter" })).toBe("anthropic");
|
|
96
|
+
expect(vendorOf({ slug: "ollama/glm-5.3-flash", provider: "ollama" })).toBe("ollama");
|
|
97
|
+
expect(vendorOf({ slug: "vllm/meta-llama/Llama-3", provider: "vllm" })).toBe("meta-llama");
|
|
98
|
+
expect(vendorOf({ slug: "azure-eu/gpt-4o-deploy", provider: "azure-eu" })).toBe("azure-eu");
|
|
99
|
+
const view = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, unserved: served }));
|
|
100
|
+
expect(view.get("vllm/meta-llama/Llama-3")!.vendor).toBe("meta-llama");
|
|
101
|
+
expect(view.get("azure-eu/gpt-4o-deploy")!.vendor).toBe("azure-eu");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("an empty policy still judges: the router's own filters and the upstream's state", () => {
|
|
105
|
+
const unserved = (p: string) => (p === "azure-eu" ? "upstream azure-eu is disabled" : null);
|
|
106
|
+
const view = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters: DEFAULT_CONFIG.filters }, unserved }));
|
|
107
|
+
expect(view.get("openai/gpt-5")).toMatchObject({ admitted: true });
|
|
108
|
+
expect("reason" in view.get("openai/gpt-5")!).toBe(false);
|
|
109
|
+
expect(view.get("anthropic/claude-opus-5:batch")).toMatchObject({ admitted: false, reason: "built-in deny: floating alias, batch endpoint, stealth, or meta-router" });
|
|
110
|
+
expect(view.get("liquid/lfm:free")).toMatchObject({ admitted: false, reason: "free models excluded (filters.includeFree)" });
|
|
111
|
+
expect(view.get("tencent/translator")).toMatchObject({ admitted: false, reason: "no tool support (filters.requireToolSupport)" });
|
|
112
|
+
expect(view.get("azure-eu/gpt-4o-deploy")).toMatchObject({ admitted: false, reason: "upstream azure-eu is disabled" });
|
|
113
|
+
// A $0 named-upstream model is self-hosted, never "free".
|
|
114
|
+
expect(view.get("vllm/meta-llama/Llama-3")).toMatchObject({ admitted: true });
|
|
115
|
+
// Filters relaxed: the same models come in.
|
|
116
|
+
const open = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters: { ...DEFAULT_CONFIG.filters, includeFree: true, requireToolSupport: false } }, unserved: served }));
|
|
117
|
+
expect(open.get("liquid/lfm:free")!.admitted).toBe(true);
|
|
118
|
+
expect(open.get("tencent/translator")!.admitted).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("allow list, deny glob and pin, in the order a turn applies them", () => {
|
|
122
|
+
const filters = { ...DEFAULT_CONFIG.filters, allow: ["anthropic/*", "vllm/*"], deny: ["*haiku*"] };
|
|
123
|
+
const view = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters }, unserved: served }));
|
|
124
|
+
expect(view.get("openai/gpt-5")).toMatchObject({ admitted: false, reason: "not in the allow list" });
|
|
125
|
+
expect(view.get("anthropic/claude-haiku-5")).toMatchObject({ admitted: false, reason: "denied by *haiku*" });
|
|
126
|
+
expect(view.get("anthropic/claude-sonnet-5")).toMatchObject({ admitted: true });
|
|
127
|
+
expect(view.get("vllm/meta-llama/Llama-3")).toMatchObject({ admitted: true });
|
|
128
|
+
// A pin keeps every other model out.
|
|
129
|
+
const pinned = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters, pin: "anthropic/claude-sonnet-5" }, unserved: served }));
|
|
130
|
+
expect(pinned.get("anthropic/claude-sonnet-5")).toMatchObject({ admitted: true });
|
|
131
|
+
expect(pinned.get("vllm/meta-llama/Llama-3")).toMatchObject({ admitted: false, reason: "pinned to anthropic/claude-sonnet-5" });
|
|
132
|
+
expect(pinned.get("openai/gpt-5")).toMatchObject({ admitted: false, reason: "not in the allow list" }); // the earlier reason stands
|
|
133
|
+
// A pin the filters drop is ignored, as select ignores it: nothing else is pinned out.
|
|
134
|
+
const dropped = bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters, pin: "anthropic/claude-haiku-5" }, unserved: served }));
|
|
135
|
+
expect(dropped.get("anthropic/claude-haiku-5")).toMatchObject({ admitted: false, reason: "denied by *haiku*" });
|
|
136
|
+
expect(dropped.get("anthropic/claude-sonnet-5")).toMatchObject({ admitted: true });
|
|
137
|
+
// So is a pin naming no model.
|
|
138
|
+
expect(bySlug(catalogView({ models: MODELS, fetchedAtMs: 0, verdict: { filters, pin: "nobody/here" }, unserved: served })).get("anthropic/claude-sonnet-5")!.admitted).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("GET /v1/router/catalog", () => {
|
|
143
|
+
let handle: StartedServer;
|
|
144
|
+
let empty: StartedServer;
|
|
145
|
+
const dir = mkdtempSync(join(tmpdir(), "amr-catalog-"));
|
|
146
|
+
const FETCHED = Date.now() - 60_000;
|
|
147
|
+
beforeAll(() => {
|
|
148
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
149
|
+
cfg.server.host = "127.0.0.1";
|
|
150
|
+
cfg.server.port = 0;
|
|
151
|
+
cfg.server.apiKey = "k";
|
|
152
|
+
cfg.logLevel = "silent";
|
|
153
|
+
// Nothing reaches the network: the catalog is served from its on-disk
|
|
154
|
+
// cache (within TTL), the periodic refresh is off, and the base URL is dead.
|
|
155
|
+
cfg.openrouter.apiKey = "sk-test";
|
|
156
|
+
cfg.openrouter.baseUrl = "http://127.0.0.1:9/api/v1";
|
|
157
|
+
cfg.openrouter.catalogRefreshMs = 0;
|
|
158
|
+
cfg.benchmarks.enabled = false;
|
|
159
|
+
cfg.upstreams = [vllm, azure];
|
|
160
|
+
cfg.ledger.path = join(dir, "router.db");
|
|
161
|
+
const db = openDb(cfg.ledger.path);
|
|
162
|
+
db.run("INSERT INTO catalog_cache (id, payload, fetched_at_ms, etag, key_scoped) VALUES (1, ?, ?, NULL, 0)", [JSON.stringify(FIXTURE.data), FETCHED]);
|
|
163
|
+
db.close();
|
|
164
|
+
handle = startServer(cfg);
|
|
165
|
+
const bare = structuredClone(cfg);
|
|
166
|
+
bare.upstreams = [];
|
|
167
|
+
bare.ledger.path = join(dir, "empty.db");
|
|
168
|
+
empty = startServer(bare);
|
|
169
|
+
});
|
|
170
|
+
afterAll(async () => {
|
|
171
|
+
await handle.stop();
|
|
172
|
+
await empty.stop();
|
|
173
|
+
try {
|
|
174
|
+
rmSync(dir, { recursive: true, force: true });
|
|
175
|
+
} catch {
|
|
176
|
+
/* Windows may hold the WAL briefly */
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
const get = (path: string, port = handle.server.port) => fetch(`http://127.0.0.1:${port}${path}`, { headers: { authorization: "Bearer k" } });
|
|
180
|
+
const view = async (path: string): Promise<Map<string, CatalogViewModel>> => bySlug((await (await get(path)).json()) as CatalogView);
|
|
181
|
+
|
|
182
|
+
test("the catalog as data: authenticated, sorted, every upstream's models, no verdict without a policy", async () => {
|
|
183
|
+
expect((await fetch(`http://127.0.0.1:${handle.server.port}/v1/router/catalog`)).status).toBe(401);
|
|
184
|
+
const res = await get("/v1/router/catalog");
|
|
185
|
+
expect(res.status).toBe(200);
|
|
186
|
+
const body = (await res.json()) as CatalogView;
|
|
187
|
+
expect(body.fetchedAtMs).toBe(FETCHED);
|
|
188
|
+
const slugs = body.models.map((m) => m.slug);
|
|
189
|
+
expect(slugs).toEqual([...slugs].sort());
|
|
190
|
+
// Every OpenRouter model the fixture normalises to, plus both named upstreams' — the disabled one too.
|
|
191
|
+
expect(slugs).toHaveLength(FIXTURE.data.filter((m) => normalizeCatalogModel(m) !== null).length + 2);
|
|
192
|
+
const models = bySlug(body);
|
|
193
|
+
const opus = models.get("anthropic/claude-opus-5")!;
|
|
194
|
+
const raw = normalizeCatalogModel(FIXTURE.data.find((m) => (m as { id: string }).id === "anthropic/claude-opus-5"))!;
|
|
195
|
+
expect(opus).toEqual({
|
|
196
|
+
slug: "anthropic/claude-opus-5",
|
|
197
|
+
canonicalSlug: raw.canonicalSlug,
|
|
198
|
+
name: raw.name,
|
|
199
|
+
provider: "openrouter",
|
|
200
|
+
vendor: "anthropic",
|
|
201
|
+
contextLength: raw.contextLength,
|
|
202
|
+
maxCompletionTokens: 128_000,
|
|
203
|
+
supportsTools: true,
|
|
204
|
+
supportsReasoning: raw.supportsReasoning,
|
|
205
|
+
reasoningMandatory: raw.reasoningMandatory,
|
|
206
|
+
inputModalities: raw.inputModalities,
|
|
207
|
+
price: { prompt: 5, completion: raw.price.completion * 1e6, cacheRead: 0.5, ...(raw.price.cacheWrite === undefined ? {} : { cacheWrite: raw.price.cacheWrite * 1e6 }) },
|
|
208
|
+
quality: raw.quality,
|
|
209
|
+
isFree: false,
|
|
210
|
+
});
|
|
211
|
+
expect(models.get("vllm/meta-llama/Llama-3")).toMatchObject({ provider: "vllm", vendor: "meta-llama", contextLength: 8_000 });
|
|
212
|
+
expect(models.get("azure-eu/gpt-4o-deploy")).toMatchObject({ provider: "azure-eu", vendor: "azure-eu", price: { prompt: 2.5, completion: 10, cacheRead: 1.25 } });
|
|
213
|
+
expect(body.models.some((m) => "admitted" in m || "reason" in m)).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("judged under a policy: allow, deny, pin, the router's filters and the upstream's state", async () => {
|
|
217
|
+
const allowed = await view(`/v1/router/catalog?policy=${encodeURIComponent(JSON.stringify({ allow: ["anthropic/*", "azure-eu/*"], deny: ["anthropic/claude-opus-5"], maxTier: "simple" }))}`);
|
|
218
|
+
expect(allowed.get("anthropic/claude-sonnet-4.5")).toMatchObject({ admitted: true });
|
|
219
|
+
expect(allowed.get("anthropic/claude-opus-5")).toMatchObject({ admitted: false, reason: "denied by anthropic/claude-opus-5" });
|
|
220
|
+
expect(allowed.get("anthropic/claude-opus-5:batch")).toMatchObject({ admitted: false, reason: "built-in deny: floating alias, batch endpoint, stealth, or meta-router" });
|
|
221
|
+
expect(allowed.get("openai/gpt-5.6-luna")).toMatchObject({ admitted: false, reason: "not in the allow list" });
|
|
222
|
+
expect(allowed.get("azure-eu/gpt-4o-deploy")).toMatchObject({ admitted: false, reason: "upstream azure-eu is disabled" });
|
|
223
|
+
// The configured deny list stays in force under a policy's, as applyRequestPolicy adds rather than replaces.
|
|
224
|
+
const pinned = await view(`/v1/router/catalog?policy=${encodeURIComponent(JSON.stringify({ pin: "vllm/meta-llama/Llama-3" }))}`);
|
|
225
|
+
expect(pinned.get("vllm/meta-llama/Llama-3")).toMatchObject({ admitted: true });
|
|
226
|
+
expect(pinned.get("anthropic/claude-sonnet-4.5")).toMatchObject({ admitted: false, reason: "pinned to vllm/meta-llama/Llama-3" });
|
|
227
|
+
expect(pinned.get("tencent/hy-mt2-1.8b")).toMatchObject({ admitted: false, reason: "no tool support (filters.requireToolSupport)" });
|
|
228
|
+
expect(pinned.get("liquid/lfm-2.5-2.6b:free")).toMatchObject({ admitted: false, reason: "free models excluded (filters.includeFree)" });
|
|
229
|
+
// `{}` is a policy too: every model carries a verdict.
|
|
230
|
+
const plain = await view("/v1/router/catalog?policy=%7B%7D");
|
|
231
|
+
expect(plain.get("anthropic/claude-sonnet-4.5")).toMatchObject({ admitted: true });
|
|
232
|
+
expect([...plain.values()].every((m) => typeof m.admitted === "boolean")).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("a malformed policy is a 400 in the wire error shape", async () => {
|
|
236
|
+
for (const bad of ["%7B", "not-json", "%5B%5D", "null", "1"]) {
|
|
237
|
+
const res = await get(`/v1/router/catalog?policy=${bad}`);
|
|
238
|
+
expect(res.status).toBe(400);
|
|
239
|
+
const body = (await res.json()) as { error: { message: string; type: string; code: string } };
|
|
240
|
+
expect(body.error.code).toBe("invalid_request_error");
|
|
241
|
+
expect(body.error.message).toContain("policy");
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("before the first fetch: empty, never a wait", async () => {
|
|
246
|
+
expect((await (await get("/v1/router/catalog", empty.server.port)).json()) as CatalogView).toEqual({ fetchedAtMs: 0, models: [] });
|
|
247
|
+
expect((await (await get("/v1/router/catalog?policy=%7B%7D", empty.server.port)).json()) as CatalogView).toEqual({ fetchedAtMs: 0, models: [] });
|
|
248
|
+
});
|
|
249
|
+
});
|
package/test/embed-logic.test.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
buildProviderConfig,
|
|
8
8
|
deriveAgentdoxScope,
|
|
9
|
+
deriveWorkspaceOrigin,
|
|
9
10
|
EMBED_PORT_FILE,
|
|
10
11
|
EMBED_PROVIDER_ID,
|
|
11
12
|
embedPortPath,
|
|
@@ -207,3 +208,87 @@ describe("agentdox scope", () => {
|
|
|
207
208
|
expect(buildProviderConfig(1234, cfg, "/x/ashlands").agentdoxScope).toBeUndefined();
|
|
208
209
|
});
|
|
209
210
|
});
|
|
211
|
+
|
|
212
|
+
describe("workspace origin", () => {
|
|
213
|
+
const CONFIG = ['[core]', '\trepositoryformatversion = 0', '[remote "origin"]', '\turl = https://github.com/DrewAppling/omp-router.git', '\tfetch = +refs/heads/*:refs/remotes/origin/*', '[branch "main"]', '\tremote = origin', ''].join("\n");
|
|
214
|
+
let root: string;
|
|
215
|
+
beforeAll(() => {
|
|
216
|
+
root = mkdtempSync(join(tmpdir(), "amr-origin-"));
|
|
217
|
+
});
|
|
218
|
+
afterAll(() => {
|
|
219
|
+
rmSync(root, { recursive: true, force: true });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a plain repository: the remote from .git/config, also from a subdirectory", () => {
|
|
223
|
+
const repo = join(root, "repo");
|
|
224
|
+
mkdirSync(join(repo, ".git"), { recursive: true });
|
|
225
|
+
writeFileSync(join(repo, ".git", "config"), CONFIG);
|
|
226
|
+
mkdirSync(join(repo, "src", "deep"), { recursive: true });
|
|
227
|
+
expect(deriveWorkspaceOrigin(repo)).toBe("github.com/drewappling/omp-router");
|
|
228
|
+
expect(deriveWorkspaceOrigin(join(repo, "src", "deep"))).toBe("github.com/drewappling/omp-router");
|
|
229
|
+
// The scope is still the folder, untouched by any of this.
|
|
230
|
+
expect(deriveAgentdoxScope(repo)).toBe("repo");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a worktree: .git is a file naming the git dir, whose shared config is one hop further", () => {
|
|
234
|
+
// The main checkout holds the config; the worktree's own dir only points at it.
|
|
235
|
+
const main = join(root, "main");
|
|
236
|
+
mkdirSync(join(main, ".git", "worktrees", "wt"), { recursive: true });
|
|
237
|
+
writeFileSync(join(main, ".git", "config"), CONFIG.replace("github.com/DrewAppling/omp-router.git", "gitlab.example.com:2222/team/api/").replace("https://", "ssh://git@"));
|
|
238
|
+
writeFileSync(join(main, ".git", "worktrees", "wt", "commondir"), "../..\n");
|
|
239
|
+
const wt = join(root, "wt");
|
|
240
|
+
mkdirSync(wt, { recursive: true });
|
|
241
|
+
writeFileSync(join(wt, ".git"), `gitdir: ${join(main, ".git", "worktrees", "wt")}\n`);
|
|
242
|
+
expect(deriveWorkspaceOrigin(wt)).toBe("gitlab.example.com/team/api");
|
|
243
|
+
// A submodule-style pointer: the named dir has its own config, relative to the .git file.
|
|
244
|
+
const sub = join(root, "sub");
|
|
245
|
+
mkdirSync(join(sub, "modules", "lib"), { recursive: true });
|
|
246
|
+
mkdirSync(join(sub, "lib"), { recursive: true });
|
|
247
|
+
writeFileSync(join(sub, "modules", "lib", "config"), CONFIG.replace("DrewAppling/omp-router", "org/lib"));
|
|
248
|
+
writeFileSync(join(sub, "lib", ".git"), "gitdir: ../modules/lib\n");
|
|
249
|
+
expect(deriveWorkspaceOrigin(join(sub, "lib"))).toBe("github.com/org/lib");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("no remote, a local remote, no repository, or nothing at all: no fingerprint, never a throw", () => {
|
|
253
|
+
const bare = join(root, "bare");
|
|
254
|
+
mkdirSync(join(bare, ".git"), { recursive: true });
|
|
255
|
+
writeFileSync(join(bare, ".git", "config"), "[core]\n\tbare = false\n");
|
|
256
|
+
expect(deriveWorkspaceOrigin(bare)).toBe("");
|
|
257
|
+
const local = join(root, "local");
|
|
258
|
+
mkdirSync(join(local, ".git"), { recursive: true });
|
|
259
|
+
writeFileSync(join(local, ".git", "config"), '[remote "origin"]\n\turl = /srv/git/local.git\n');
|
|
260
|
+
expect(deriveWorkspaceOrigin(local)).toBe("");
|
|
261
|
+
// A `.git` file that leads nowhere is the boundary: the walk stops there.
|
|
262
|
+
const dangling = join(root, "repo", "dangling");
|
|
263
|
+
mkdirSync(dangling, { recursive: true });
|
|
264
|
+
writeFileSync(join(dangling, ".git"), "gitdir: /nowhere/at/all\n");
|
|
265
|
+
expect(deriveWorkspaceOrigin(dangling)).toBe("");
|
|
266
|
+
expect(deriveWorkspaceOrigin(join(root, "not-a-repo", "missing"))).toBe("");
|
|
267
|
+
expect(deriveWorkspaceOrigin("")).toBe("");
|
|
268
|
+
expect(
|
|
269
|
+
deriveWorkspaceOrigin("/anywhere", () => {
|
|
270
|
+
throw new Error("disk on fire");
|
|
271
|
+
}),
|
|
272
|
+
).toBe("");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("the first url under [remote \"origin\"] wins; other remotes do not count", () => {
|
|
276
|
+
const read = () => '[remote "upstream"]\n\turl = https://github.com/other/thing.git\n[remote "origin"]\n\turl = git@github.com:me/thing.git\n\turl = https://github.com/me/second.git\n';
|
|
277
|
+
expect(deriveWorkspaceOrigin("/x/repo", read)).toBe("github.com/me/thing");
|
|
278
|
+
expect(deriveWorkspaceOrigin("/x/repo", () => '[remote "upstream"]\n\turl = https://github.com/other/thing.git\n')).toBe("");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("buildProviderConfig carries the origin beside the scope, only where the scope goes", () => {
|
|
282
|
+
const base = {
|
|
283
|
+
server: { host: "127.0.0.1" },
|
|
284
|
+
profiles: [],
|
|
285
|
+
ledger: { fallbackBlend: { inputPerMtok: 1, outputPerMtok: 1 } },
|
|
286
|
+
};
|
|
287
|
+
const on = buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands", "github.com/me/ashlands");
|
|
288
|
+
expect(on.agentdoxScope).toBe("ashlands");
|
|
289
|
+
expect(on.agentdoxOrigin).toBe("github.com/me/ashlands");
|
|
290
|
+
expect(buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands", "").agentdoxOrigin).toBeUndefined();
|
|
291
|
+
expect(buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands").agentdoxOrigin).toBeUndefined();
|
|
292
|
+
expect(buildProviderConfig(1234, { ...base, context: { enabled: false, defaultScope: "" } }, "/x/ashlands", "github.com/me/ashlands").agentdoxOrigin).toBeUndefined();
|
|
293
|
+
});
|
|
294
|
+
});
|
package/test/escalate.test.ts
CHANGED
package/test/failover.test.ts
CHANGED
|
@@ -98,6 +98,7 @@ function mkReq(): NormRequest {
|
|
|
98
98
|
agentdoxScope: "",
|
|
99
99
|
agentdoxGroup: "",
|
|
100
100
|
agentdoxPersonal: "",
|
|
101
|
+
agentdoxOrigin: "",
|
|
101
102
|
isSubagent: false,
|
|
102
103
|
requestedModel: "auto",
|
|
103
104
|
messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
|