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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +44 -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/cli/config-cmd.ts +5 -2
- package/src/cli/config-wizard.ts +1 -0
- package/src/cli/connect.ts +6 -2
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +9 -0
- package/src/context/agentdox.ts +25 -4
- package/src/context/bridge.ts +34 -9
- package/src/context/index.ts +1 -0
- package/src/context/scope.ts +63 -0
- package/src/context/types.ts +20 -0
- package/src/server/advise.ts +3 -0
- package/src/server/digest.ts +3 -0
- package/src/server/turn.ts +6 -0
- package/src/wire/openai/request.ts +15 -1
- package/src/wire/types.ts +22 -0
- package/test/anthropic-wire.test.ts +22 -0
- package/test/context-bridge.test.ts +148 -3
- package/test/embed-logic.test.ts +86 -1
- package/test/escalate.test.ts +3 -0
- package/test/failover.test.ts +4 -1
- package/test/models-yml.test.ts +10 -0
- package/test/remote.test.ts +8 -1
- package/test/scope.test.ts +74 -1
- package/test/turn.test.ts +40 -2
- package/tools/agentdox-e2e.ts +6 -0
- package/tools/replay.ts +3 -0
package/test/scope.test.ts
CHANGED
|
@@ -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();
|
package/tools/agentdox-e2e.ts
CHANGED
|
@@ -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,
|