auto-model-router 0.15.0 → 0.17.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.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { SCOPE_ENV, acceptScope, isScopeSlug } from "../src/context/scope.ts";
2
+ import { ORIGIN_ENV, SCOPE_ENV, acceptOrigin, acceptScope, isOrigin, isScopeSlug, normalizeOrigin } from "../src/context/scope.ts";
3
3
  import { parseChatRequest } from "../src/wire/openai/request.ts";
4
4
 
5
5
  describe("the scope a request may name", () => {
@@ -28,4 +28,77 @@ describe("the scope a request may name", () => {
28
28
  expect(parse("omp-router").agentdoxScope).toBe("omp-router");
29
29
  expect(parse(SCOPE_ENV).agentdoxScope).toBe("");
30
30
  });
31
+
32
+ test("the wire parser reads X-Agentdox-Group and X-Agentdox-Personal by the same rule", () => {
33
+ // A team front door names the group-context and personal scopes; a
34
+ // lone router never sees the headers and must end up with empties, so
35
+ // nothing new is sent to agentdox.
36
+ const body = { model: "auto", messages: [{ role: "user", content: "hi" }] };
37
+ const parse = (h: Record<string, string>) => parseChatRequest(body, new Headers(h));
38
+ const none = parse({ "x-agentdox-scope": "omp-router" });
39
+ expect(none.agentdoxGroup).toBe("");
40
+ expect(none.agentdoxPersonal).toBe("");
41
+ const both = parse({ "x-agentdox-scope": "omp-router", "x-agentdox-group": " group.g1 ", "x-agentdox-personal": "omp-router.u.u_ada" });
42
+ expect(both.agentdoxGroup).toBe("group.g1");
43
+ expect(both.agentdoxPersonal).toBe("omp-router.u.u_ada");
44
+ // Not slugs: dropped, never passed through.
45
+ const bad = parse({ "x-agentdox-group": "Has Spaces", "x-agentdox-personal": SCOPE_ENV });
46
+ expect(bad.agentdoxGroup).toBe("");
47
+ expect(bad.agentdoxPersonal).toBe("");
48
+ // Layers without a project scope are still parsed; the turn decides what to do.
49
+ expect(parse({ "x-agentdox-group": "group.g1" }).agentdoxGroup).toBe("group.g1");
50
+ });
51
+ });
52
+
53
+ describe("the origin a request may name", () => {
54
+ test("normalizeOrigin reduces every remote form to host/path, and a local path to nothing", () => {
55
+ // The same repository over https and ssh is one fingerprint.
56
+ expect(normalizeOrigin("https://github.com/DrewAppling/omp-router.git")).toBe("github.com/drewappling/omp-router");
57
+ expect(normalizeOrigin("git@github.com:drewappling/omp-router.git")).toBe("github.com/drewappling/omp-router");
58
+ expect(normalizeOrigin("ssh://git@gitlab.example.com:2222/team/api/")).toBe("gitlab.example.com/team/api");
59
+ // Credentials and the port never reach the value.
60
+ expect(normalizeOrigin("https://user:p4ss@host.example:8443/a/b.git")).toBe("host.example/a/b");
61
+ expect(normalizeOrigin("host.example:repo")).toBe("host.example/repo");
62
+ expect(normalizeOrigin(" https://github.com/a/b/.git ")).toBe("github.com/a/b");
63
+ // No host, no fingerprint: a local clone says nothing about the project.
64
+ expect(normalizeOrigin("file:///e/projects/x")).toBe("");
65
+ expect(normalizeOrigin("/local/path")).toBe("");
66
+ expect(normalizeOrigin("C:\\path")).toBe("");
67
+ expect(normalizeOrigin("C:\\Users\\drew\\repo")).toBe("");
68
+ expect(normalizeOrigin("C:/path")).toBe("");
69
+ expect(normalizeOrigin("https://github.com/")).toBe("");
70
+ expect(normalizeOrigin("")).toBe("");
71
+ });
72
+
73
+ test("isOrigin: host/path only; the env-var sentinel never passes", () => {
74
+ expect(isOrigin("github.com/drewappling/omp-router")).toBe(true);
75
+ expect(isOrigin("gitlab.example.com/team/sub/api")).toBe(true);
76
+ // omp sends the header's literal value when the variable it names is
77
+ // unset: the NAME must never become a fingerprint.
78
+ expect(isOrigin(ORIGIN_ENV)).toBe(false);
79
+ expect(isOrigin("github.com")).toBe(false);
80
+ expect(isOrigin("GitHub.com/a/b")).toBe(false);
81
+ expect(isOrigin("https://github.com/a/b")).toBe(false);
82
+ expect(isOrigin("github.com/a/b/")).toBe(false);
83
+ expect(isOrigin("")).toBe(false);
84
+ });
85
+
86
+ test("acceptOrigin trims and rejects", () => {
87
+ expect(acceptOrigin(" github.com/a/b ")).toBe("github.com/a/b");
88
+ expect(acceptOrigin(ORIGIN_ENV)).toBe("");
89
+ expect(acceptOrigin("https://github.com/a/b")).toBe("");
90
+ expect(acceptOrigin(null)).toBe("");
91
+ });
92
+
93
+ test("the wire parser reads X-Agentdox-Origin by that rule and nothing else changes", () => {
94
+ const body = { model: "auto", messages: [{ role: "user", content: "hi" }] };
95
+ const parse = (h: Record<string, string>) => parseChatRequest(body, new Headers(h));
96
+ expect(parse({ "x-agentdox-scope": "omp-router" }).agentdoxOrigin).toBe("");
97
+ const both = parse({ "x-agentdox-scope": "omp-router", "x-agentdox-origin": "github.com/drewappling/omp-router" });
98
+ expect(both.agentdoxScope).toBe("omp-router");
99
+ expect(both.agentdoxOrigin).toBe("github.com/drewappling/omp-router");
100
+ // The sentinel and a raw URL are dropped, never passed through.
101
+ expect(parse({ "x-agentdox-origin": ORIGIN_ENV }).agentdoxOrigin).toBe("");
102
+ expect(parse({ "x-agentdox-origin": "https://github.com/a/b" }).agentdoxOrigin).toBe("");
103
+ });
31
104
  });
package/test/turn.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createDisabledBridge } from "../src/context/bridge.ts";
3
- import type { ContextBridge, TurnRecord } from "../src/context/types.ts";
3
+ import type { ContextBridge, ContextResolveInput, TurnRecord } from "../src/context/types.ts";
4
4
  import type { CatalogModel, CatalogSource } from "../src/catalog/types.ts";
5
5
  import type { EscalationConfig, RouterConfig } from "../src/config/types.ts";
6
6
  import { EMPTY_USAGE, type Ledger, type LedgerEntry, type UsageCounts } from "../src/cost/types.ts";
@@ -74,7 +74,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
74
74
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
75
75
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
76
76
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
77
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
77
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, layers: true, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
78
78
  compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
79
79
  budget: { onExceeded: "downgrade" },
80
80
  report: { baselines: [], dailySummary: false },
@@ -96,6 +96,9 @@ function mkReq(): NormRequest {
96
96
  harnessId: "",
97
97
  ompSessionId: "",
98
98
  agentdoxScope: "",
99
+ agentdoxGroup: "",
100
+ agentdoxPersonal: "",
101
+ agentdoxOrigin: "",
99
102
  isSubagent: false,
100
103
  requestedModel: "auto",
101
104
  messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
@@ -722,6 +725,41 @@ describe("agentdox injection sees the shape of the turn", () => {
722
725
  expect(store.load(req.conversationKey).contextVersion).toBe("v1");
723
726
  });
724
727
 
728
+ test("the layer headers and the harness id reach resolve, and the harness id reaches the record", async () => {
729
+ // The team front door names the group and personal scopes in headers and
730
+ // the member in X-Omp-Harness; the turn carries all three through
731
+ // unchanged. A router without a team passes empties (see the other tests
732
+ // in this file, whose mkReq has none).
733
+ const { router, upstream, ledger, store, sink, errors } = oneDispatch();
734
+ const inputs: ContextResolveInput[] = [];
735
+ const records: TurnRecord[] = [];
736
+ const bridge: ContextBridge = {
737
+ enabled: true,
738
+ resolve: (input) => {
739
+ inputs.push(input);
740
+ return Promise.resolve(null);
741
+ },
742
+ recordTurn: (rec) => {
743
+ records.push(rec);
744
+ },
745
+ flush: () => Promise.resolve(),
746
+ pruneBlocks: () => 0,
747
+ close: () => {},
748
+ };
749
+ const req: NormRequest = { ...mkReq(), harnessId: "u_ada", agentdoxScope: "proj", agentdoxGroup: "group.g1", agentdoxPersonal: "proj.u.u_ada", tools: [AGENT_TOOL] };
750
+
751
+ await runTurn(req, sink, { config: mkConfig({ enabled: false }), router, upstream, ledger, conversations: store, catalog, context: bridge }, new AbortController().signal);
752
+
753
+ expect(errors).toHaveLength(0);
754
+ expect(inputs).toHaveLength(1);
755
+ expect(inputs[0]?.scope).toBe("proj");
756
+ expect(inputs[0]?.group).toBe("group.g1");
757
+ expect(inputs[0]?.personal).toBe("proj.u.u_ada");
758
+ expect(inputs[0]?.user).toBe("u_ada");
759
+ expect(records).toHaveLength(1);
760
+ expect(records[0]?.harnessId).toBe("u_ada");
761
+ });
762
+
725
763
  test("context.injectWithoutTools restores injection into tool-less calls", async () => {
726
764
  const { router, upstream, ledger, store, sink, errors } = oneDispatch();
727
765
  const { bridge, resolves } = mkServingBridge();
@@ -44,6 +44,7 @@ const bridge = createContextBridge({
44
44
  docsLimit: 0,
45
45
  sessionLimit: 6,
46
46
  briefChars: 12_000,
47
+ layers: true,
47
48
  recordTurns: true,
48
49
  maxQueue: 64,
49
50
  });
@@ -58,6 +59,9 @@ function input(over: Partial<ContextResolveInput> = {}): ContextResolveInput {
58
59
  modelSwitching: false,
59
60
  retrying: false,
60
61
  query: "cache prompt context injection",
62
+ group: "",
63
+ personal: "",
64
+ user: "",
61
65
  firstFetch: true,
62
66
  ...over,
63
67
  };
@@ -93,6 +97,7 @@ check(
93
97
  bridge.recordTurn({
94
98
  scope,
95
99
  conversationKey,
100
+ harnessId: "",
96
101
  title: `bridge e2e ${conversationKey}`,
97
102
  userText: "does the bridge record which model served this turn?",
98
103
  assistantText: "yes - refs carry model: and tier:.",
@@ -135,6 +140,7 @@ function loopDispatch(assistantText: string, turnEnded: boolean): void {
135
140
  bridge.recordTurn({
136
141
  scope,
137
142
  conversationKey: loopKey,
143
+ harnessId: "",
138
144
  title: loopTitle,
139
145
  userText: "why did cache read fall?",
140
146
  assistantText,
package/tools/replay.ts CHANGED
@@ -197,6 +197,9 @@ function requestOf(row: Row, f: Features): NormRequest {
197
197
  harnessId: row.harness_id,
198
198
  ompSessionId: "",
199
199
  agentdoxScope: "",
200
+ agentdoxGroup: "",
201
+ agentdoxPersonal: "",
202
+ agentdoxOrigin: "",
200
203
  isSubagent: false,
201
204
  requestedModel: row.requested_model,
202
205
  messages,