@tokenbuddy/tokenbuddy 1.0.31 → 1.0.34
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/dist/src/buyer-store.d.ts +26 -0
- package/dist/src/buyer-store.d.ts.map +1 -1
- package/dist/src/buyer-store.js +61 -8
- package/dist/src/buyer-store.js.map +1 -1
- package/dist/src/daemon.d.ts +24 -0
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +1259 -9
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/doctor-diagnostics.d.ts +12 -0
- package/dist/src/doctor-diagnostics.d.ts.map +1 -1
- package/dist/src/doctor-diagnostics.js +71 -2
- package/dist/src/doctor-diagnostics.js.map +1 -1
- package/dist/src/prewarm-cache.js +4 -1
- package/dist/src/prewarm-cache.js.map +1 -1
- package/dist/src/provider-routing-config.d.ts +91 -0
- package/dist/src/provider-routing-config.d.ts.map +1 -0
- package/dist/src/provider-routing-config.js +292 -0
- package/dist/src/provider-routing-config.js.map +1 -0
- package/package.json +1 -1
- package/src/buyer-store.ts +113 -8
- package/src/daemon.ts +1389 -16
- package/src/doctor-diagnostics.ts +100 -1
- package/src/prewarm-cache.ts +5 -1
- package/src/provider-routing-config.ts +410 -0
- package/static/ui/assets/index-0MVXD7bH.css +1 -0
- package/static/ui/assets/index-Mt3BZFuP.js +266 -0
- package/static/ui/assets/index-Mt3BZFuP.js.map +1 -0
- package/static/ui/icons/apple-touch-icon.png +0 -0
- package/static/ui/icons/tokenbuddy-192.png +0 -0
- package/static/ui/icons/tokenbuddy-512.png +0 -0
- package/static/ui/icons/tokenbuddy.svg +6 -0
- package/static/ui/index.html +3 -2
- package/static/ui/tool-logos/cc-switch.png +0 -0
- package/static/ui/tool-logos/claude.svg +1 -0
- package/static/ui/tool-logos/cline.svg +1 -0
- package/static/ui/tool-logos/codex.svg +1 -0
- package/static/ui/tool-logos/cursor.svg +1 -0
- package/static/ui/tool-logos/dirac.ico +18 -0
- package/static/ui/tool-logos/factory.svg +4 -0
- package/static/ui/tool-logos/fast-agent.svg +6 -0
- package/static/ui/tool-logos/glm.svg +1 -0
- package/static/ui/tool-logos/goose.svg +1 -0
- package/static/ui/tool-logos/hermes.svg +1 -0
- package/static/ui/tool-logos/kilocode.svg +1 -0
- package/static/ui/tool-logos/opencode.svg +1 -0
- package/static/ui/tool-logos/pi.svg +28 -0
- package/static/ui/tool-logos/qwen-code.png +0 -0
- package/tests/cli-routing.test.ts +43 -0
- package/tests/control-plane-ui-endpoints.test.ts +776 -0
- package/tests/daemon-classify.test.ts +5 -1
- package/tests/e2e.test.ts +5 -0
- package/tests/prewarm-cache.test.ts +15 -0
- package/tests/provider-routing-config.test.ts +150 -0
- package/tests/tokenbuddy.test.ts +27 -0
- package/static/ui/assets/index-Bzbrp7Qe.css +0 -1
- package/static/ui/assets/index-DEDEl8o2.js +0 -236
- package/static/ui/assets/index-DEDEl8o2.js.map +0 -1
|
@@ -6,6 +6,8 @@ import { TokenbuddyDaemon, type DaemonConfig } from "../src/daemon.js";
|
|
|
6
6
|
import { DEFAULT_INIT_RECOMMENDED_MODELS } from "../src/init-setup.js";
|
|
7
7
|
import type { SellerRegistryDocument } from "../src/seller-catalog.js";
|
|
8
8
|
|
|
9
|
+
jest.setTimeout(30000);
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* tb-ui v1 (PR-0) 控制平面写端点集成测试。
|
|
11
13
|
*
|
|
@@ -87,6 +89,24 @@ describe("TokenbuddyDaemon control-plane UI endpoints (PR-0)", () => {
|
|
|
87
89
|
return `http://127.0.0.1:${controlPort}${path}`;
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
function proxyUrl(path: string): string {
|
|
93
|
+
return `http://127.0.0.1:${proxyPort}${path}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function startJsonServer(handler: http.RequestListener): Promise<{ server: http.Server; url: string }> {
|
|
97
|
+
const server = http.createServer(handler);
|
|
98
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
99
|
+
const address = server.address() as AddressInfo;
|
|
100
|
+
return {
|
|
101
|
+
server,
|
|
102
|
+
url: `http://127.0.0.1:${address.port}`
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function closeServer(server: http.Server): Promise<void> {
|
|
107
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
108
|
+
}
|
|
109
|
+
|
|
90
110
|
beforeEach(async () => {
|
|
91
111
|
await startDaemon();
|
|
92
112
|
});
|
|
@@ -99,8 +119,76 @@ describe("TokenbuddyDaemon control-plane UI endpoints (PR-0)", () => {
|
|
|
99
119
|
rmDb();
|
|
100
120
|
});
|
|
101
121
|
|
|
122
|
+
function markClawtipReady(): void {
|
|
123
|
+
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
124
|
+
const configsDir = path.join(TEMP_HOME, ".openclaw", "configs");
|
|
125
|
+
fs.mkdirSync(configsDir, { recursive: true });
|
|
126
|
+
fs.writeFileSync(path.join(configsDir, "config.json"), "{}", "utf8");
|
|
127
|
+
store.savePayment({
|
|
128
|
+
method: "clawtip",
|
|
129
|
+
enabled: true,
|
|
130
|
+
isDefault: true,
|
|
131
|
+
config: {
|
|
132
|
+
resourceUrl: "https://tb-registry.example.test",
|
|
133
|
+
walletConfigPresent: true
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
102
138
|
// ─── ClawTip payment QR endpoints ─────────────────────────────
|
|
103
139
|
describe("ClawTip payment QR endpoints", () => {
|
|
140
|
+
it("selects a configured payment method as default", async () => {
|
|
141
|
+
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void; listPayments(): Array<{ method: string; isDefault: boolean }> } }).tokenStore;
|
|
142
|
+
store.savePayment({
|
|
143
|
+
method: "clawtip",
|
|
144
|
+
enabled: false,
|
|
145
|
+
isDefault: true,
|
|
146
|
+
config: {
|
|
147
|
+
walletConfigPresent: false
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
store.savePayment({
|
|
151
|
+
method: "invoice",
|
|
152
|
+
enabled: true,
|
|
153
|
+
isDefault: false,
|
|
154
|
+
config: {
|
|
155
|
+
walletConfigPresent: true
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const res = await fetch(controlUrl("/payments/default"), {
|
|
160
|
+
method: "PUT",
|
|
161
|
+
headers: { "content-type": "application/json" },
|
|
162
|
+
body: JSON.stringify({ method: "invoice" })
|
|
163
|
+
});
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
const body = await res.json() as { payments: Array<{ method: string; isDefault: boolean }> };
|
|
166
|
+
expect(body.payments.find((payment) => payment.method === "invoice")?.isDefault).toBe(true);
|
|
167
|
+
expect(body.payments.find((payment) => payment.method === "clawtip")?.isDefault).toBe(false);
|
|
168
|
+
expect(store.listPayments().find((payment) => payment.method === "invoice")?.isDefault).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("rejects selecting an unbound payment method as default", async () => {
|
|
172
|
+
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
173
|
+
store.savePayment({
|
|
174
|
+
method: "invoice",
|
|
175
|
+
enabled: false,
|
|
176
|
+
isDefault: false,
|
|
177
|
+
config: {
|
|
178
|
+
walletConfigPresent: false
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const res = await fetch(controlUrl("/payments/default"), {
|
|
183
|
+
method: "PUT",
|
|
184
|
+
headers: { "content-type": "application/json" },
|
|
185
|
+
body: JSON.stringify({ method: "invoice" })
|
|
186
|
+
});
|
|
187
|
+
expect(res.status).toBe(400);
|
|
188
|
+
const body = await res.json() as { error: { code: string } };
|
|
189
|
+
expect(body.error.code).toBe("payment_default_not_ready");
|
|
190
|
+
});
|
|
191
|
+
|
|
104
192
|
it("reflects a renamed wallet config as unbound in /payments", async () => {
|
|
105
193
|
const store = (daemon as unknown as { tokenStore: { savePayment(input: unknown): void } }).tokenStore;
|
|
106
194
|
const configsDir = path.join(TEMP_HOME, ".openclaw", "configs");
|
|
@@ -627,6 +715,694 @@ describe("TokenbuddyDaemon control-plane UI endpoints (PR-0)", () => {
|
|
|
627
715
|
});
|
|
628
716
|
});
|
|
629
717
|
|
|
718
|
+
describe("provider mode and provider configs", () => {
|
|
719
|
+
it("defaults to manual mode and reports auto as locked without payment", async () => {
|
|
720
|
+
const res = await fetch(controlUrl("/routing/provider-mode"));
|
|
721
|
+
expect(res.status).toBe(200);
|
|
722
|
+
const body = await res.json() as {
|
|
723
|
+
mode: string;
|
|
724
|
+
manualEnabled: boolean;
|
|
725
|
+
autoEnabled: boolean;
|
|
726
|
+
paymentReady: boolean;
|
|
727
|
+
paymentRequired: boolean;
|
|
728
|
+
locked: { auto: boolean };
|
|
729
|
+
};
|
|
730
|
+
expect(body.mode).toBe("manual");
|
|
731
|
+
expect(body.manualEnabled).toBe(true);
|
|
732
|
+
expect(body.autoEnabled).toBe(false);
|
|
733
|
+
expect(body.paymentReady).toBe(false);
|
|
734
|
+
expect(body.paymentRequired).toBe(true);
|
|
735
|
+
expect(body.locked.auto).toBe(true);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("rejects enabling auto mode until a non-mock payment is ready", async () => {
|
|
739
|
+
const res = await fetch(controlUrl("/routing/provider-mode"), {
|
|
740
|
+
method: "PUT",
|
|
741
|
+
headers: { "content-type": "application/json" },
|
|
742
|
+
body: JSON.stringify({ mode: "auto" })
|
|
743
|
+
});
|
|
744
|
+
expect(res.status).toBe(409);
|
|
745
|
+
const body = await res.json() as { error: { code: string }; bindTarget: string };
|
|
746
|
+
expect(body.error.code).toBe("payment_required");
|
|
747
|
+
expect(body.bindTarget).toBe("/overview?bind=clawtip");
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("enables auto mode after ClawTip is bound", async () => {
|
|
751
|
+
markClawtipReady();
|
|
752
|
+
const res = await fetch(controlUrl("/routing/provider-mode"), {
|
|
753
|
+
method: "PUT",
|
|
754
|
+
headers: { "content-type": "application/json" },
|
|
755
|
+
body: JSON.stringify({ mode: "auto" })
|
|
756
|
+
});
|
|
757
|
+
expect(res.status).toBe(200);
|
|
758
|
+
const body = await res.json() as {
|
|
759
|
+
applied: boolean;
|
|
760
|
+
mode: string;
|
|
761
|
+
manualEnabled: boolean;
|
|
762
|
+
autoEnabled: boolean;
|
|
763
|
+
paymentReady: boolean;
|
|
764
|
+
paymentLabel?: string;
|
|
765
|
+
};
|
|
766
|
+
expect(body.applied).toBe(true);
|
|
767
|
+
expect(body.mode).toBe("auto");
|
|
768
|
+
expect(body.manualEnabled).toBe(false);
|
|
769
|
+
expect(body.autoEnabled).toBe(true);
|
|
770
|
+
expect(body.paymentReady).toBe(true);
|
|
771
|
+
expect(body.paymentLabel).toBe("ClawTip");
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("creates, lists, and deletes manual providers without exposing secret fields", async () => {
|
|
775
|
+
const createRes = await fetch(controlUrl("/routing/manual-providers"), {
|
|
776
|
+
method: "POST",
|
|
777
|
+
headers: { "content-type": "application/json" },
|
|
778
|
+
body: JSON.stringify({
|
|
779
|
+
id: "local-openai",
|
|
780
|
+
name: "Local OpenAI",
|
|
781
|
+
baseUrl: "https://api.openai.example/v1/",
|
|
782
|
+
apiKeyEnv: "TB_TEST_PROVIDER_OPENAI_KEY",
|
|
783
|
+
models: ["gpt-4o", "gpt-4o"],
|
|
784
|
+
supportedProtocols: ["chat_completions"],
|
|
785
|
+
enabled: true
|
|
786
|
+
})
|
|
787
|
+
});
|
|
788
|
+
expect(createRes.status).toBe(201);
|
|
789
|
+
const created = await createRes.json() as {
|
|
790
|
+
provider: { id: string; baseUrl: string; keyRef?: { kind: string; name: string; configured: boolean }; apiKeyEnv?: string };
|
|
791
|
+
};
|
|
792
|
+
expect(created.provider.id).toBe("local-openai");
|
|
793
|
+
expect(created.provider.baseUrl).toBe("https://api.openai.example/v1");
|
|
794
|
+
expect(created.provider.keyRef).toEqual({
|
|
795
|
+
kind: "env",
|
|
796
|
+
name: "TB_TEST_PROVIDER_OPENAI_KEY",
|
|
797
|
+
configured: false
|
|
798
|
+
});
|
|
799
|
+
expect(created.provider.apiKeyEnv).toBeUndefined();
|
|
800
|
+
|
|
801
|
+
const listRes = await fetch(controlUrl("/routing/manual-providers"));
|
|
802
|
+
expect(listRes.status).toBe(200);
|
|
803
|
+
const listBody = await listRes.json() as { providers: Array<{ id: string; apiKeyEnv?: string }> };
|
|
804
|
+
expect(listBody.providers.map((provider) => provider.id)).toEqual(["local-openai"]);
|
|
805
|
+
expect(listBody.providers[0].apiKeyEnv).toBeUndefined();
|
|
806
|
+
|
|
807
|
+
const deleteRes = await fetch(controlUrl("/routing/manual-providers/local-openai"), { method: "DELETE" });
|
|
808
|
+
expect(deleteRes.status).toBe(200);
|
|
809
|
+
const afterDeleteRes = await fetch(controlUrl("/routing/manual-providers"));
|
|
810
|
+
const afterDelete = await afterDeleteRes.json() as { providers: unknown[] };
|
|
811
|
+
expect(afterDelete.providers).toEqual([]);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("rejects manual providers with raw API keys", async () => {
|
|
815
|
+
const res = await fetch(controlUrl("/routing/manual-providers"), {
|
|
816
|
+
method: "POST",
|
|
817
|
+
headers: { "content-type": "application/json" },
|
|
818
|
+
body: JSON.stringify({
|
|
819
|
+
id: "local-openai",
|
|
820
|
+
name: "Local OpenAI",
|
|
821
|
+
baseUrl: "https://api.openai.example/v1",
|
|
822
|
+
apiKey: "sk-secret",
|
|
823
|
+
models: ["gpt-4o"]
|
|
824
|
+
})
|
|
825
|
+
});
|
|
826
|
+
expect(res.status).toBe(400);
|
|
827
|
+
const body = await res.json() as { error: { code: string; message: string } };
|
|
828
|
+
expect(body.error.code).toBe("manual_provider_create_failed");
|
|
829
|
+
expect(body.error.message).toMatch(/raw apiKey/);
|
|
830
|
+
expect(body.error.message).not.toContain("sk-secret");
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("probes and creates a local manual provider without exposing the raw API key", async () => {
|
|
834
|
+
const providerKey = "provider-local-ui-key";
|
|
835
|
+
const provider = await startJsonServer((req, res) => {
|
|
836
|
+
expect(req.headers.authorization).toBe(`Bearer ${providerKey}`);
|
|
837
|
+
if (req.method === "GET" && req.url === "/v1/models") {
|
|
838
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
839
|
+
res.end(JSON.stringify({
|
|
840
|
+
object: "list",
|
|
841
|
+
data: [
|
|
842
|
+
{ id: "gpt-local-ui", object: "model" },
|
|
843
|
+
{ id: "gpt-local-ui-fast", object: "model" }
|
|
844
|
+
]
|
|
845
|
+
}));
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (req.method === "POST" && req.url === "/v1/chat/completions") {
|
|
849
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
850
|
+
res.end(JSON.stringify({
|
|
851
|
+
id: "chatcmpl-local-ui",
|
|
852
|
+
object: "chat.completion",
|
|
853
|
+
choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
|
|
854
|
+
usage: { prompt_tokens: 4, completion_tokens: 3 }
|
|
855
|
+
}));
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
859
|
+
res.end(JSON.stringify({ error: { message: "not found" } }));
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
const probeRes = await fetch(controlUrl("/routing/manual-providers/probe"), {
|
|
864
|
+
method: "POST",
|
|
865
|
+
headers: { "content-type": "application/json" },
|
|
866
|
+
body: JSON.stringify({
|
|
867
|
+
baseUrl: `${provider.url}/v1`,
|
|
868
|
+
apiKey: providerKey
|
|
869
|
+
})
|
|
870
|
+
});
|
|
871
|
+
expect(probeRes.status).toBe(200);
|
|
872
|
+
const probeBody = await probeRes.json() as { modelIds: string[] };
|
|
873
|
+
expect(probeBody.modelIds).toEqual(["gpt-local-ui", "gpt-local-ui-fast"]);
|
|
874
|
+
|
|
875
|
+
const createRes = await fetch(controlUrl("/routing/manual-providers/local"), {
|
|
876
|
+
method: "POST",
|
|
877
|
+
headers: { "content-type": "application/json" },
|
|
878
|
+
body: JSON.stringify({
|
|
879
|
+
id: "local-ui",
|
|
880
|
+
name: "Local UI",
|
|
881
|
+
baseUrl: `${provider.url}/v1`,
|
|
882
|
+
apiKey: providerKey
|
|
883
|
+
})
|
|
884
|
+
});
|
|
885
|
+
expect(createRes.status).toBe(201);
|
|
886
|
+
const created = await createRes.json() as {
|
|
887
|
+
provider: { id: string; models: string[]; keyRef?: { kind: string; name: string; configured: boolean }; apiKey?: string };
|
|
888
|
+
};
|
|
889
|
+
expect(created.provider).toMatchObject({
|
|
890
|
+
id: "local-ui",
|
|
891
|
+
models: ["gpt-local-ui", "gpt-local-ui-fast"],
|
|
892
|
+
keyRef: { kind: "secret", name: "local:local-ui", configured: true }
|
|
893
|
+
});
|
|
894
|
+
expect(created.provider.apiKey).toBeUndefined();
|
|
895
|
+
expect(JSON.stringify(created)).not.toContain(providerKey);
|
|
896
|
+
|
|
897
|
+
const modelsRes = await fetch(proxyUrl("/v1/models"));
|
|
898
|
+
expect(modelsRes.status).toBe(200);
|
|
899
|
+
const models = await modelsRes.json() as { data: Array<{ id: string; sellerId: string }> };
|
|
900
|
+
expect(models.data).toEqual(expect.arrayContaining([
|
|
901
|
+
expect.objectContaining({ id: "gpt-local-ui", sellerId: "local-ui" })
|
|
902
|
+
]));
|
|
903
|
+
|
|
904
|
+
const chatRes = await fetch(proxyUrl("/v1/chat/completions"), {
|
|
905
|
+
method: "POST",
|
|
906
|
+
headers: { "content-type": "application/json" },
|
|
907
|
+
body: JSON.stringify({
|
|
908
|
+
model: "gpt-local-ui",
|
|
909
|
+
messages: [{ role: "user", content: "hello" }]
|
|
910
|
+
})
|
|
911
|
+
});
|
|
912
|
+
expect(chatRes.status).toBe(200);
|
|
913
|
+
} finally {
|
|
914
|
+
await closeServer(provider.server);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("updates a local manual provider using the stored secret", async () => {
|
|
919
|
+
const providerKey = "provider-local-edit-key";
|
|
920
|
+
const provider = await startJsonServer((req, res) => {
|
|
921
|
+
expect(req.headers.authorization).toBe(`Bearer ${providerKey}`);
|
|
922
|
+
if (req.method === "GET" && req.url === "/v1/models") {
|
|
923
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
924
|
+
res.end(JSON.stringify({
|
|
925
|
+
object: "list",
|
|
926
|
+
data: [
|
|
927
|
+
{ id: "gpt-local-edited", object: "model" },
|
|
928
|
+
{ id: "gpt-local-edited-fast", object: "model" }
|
|
929
|
+
]
|
|
930
|
+
}));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
934
|
+
res.end(JSON.stringify({ error: { message: "not found" } }));
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
try {
|
|
938
|
+
const createRes = await fetch(controlUrl("/routing/manual-providers/local"), {
|
|
939
|
+
method: "POST",
|
|
940
|
+
headers: { "content-type": "application/json" },
|
|
941
|
+
body: JSON.stringify({
|
|
942
|
+
id: "local-edit",
|
|
943
|
+
name: "Local Edit",
|
|
944
|
+
baseUrl: `${provider.url}/v1`,
|
|
945
|
+
apiKey: providerKey
|
|
946
|
+
})
|
|
947
|
+
});
|
|
948
|
+
expect(createRes.status).toBe(201);
|
|
949
|
+
|
|
950
|
+
const updateRes = await fetch(controlUrl("/routing/manual-providers/local/local-edit"), {
|
|
951
|
+
method: "PUT",
|
|
952
|
+
headers: { "content-type": "application/json" },
|
|
953
|
+
body: JSON.stringify({
|
|
954
|
+
name: "Local Edited",
|
|
955
|
+
baseUrl: `${provider.url}/v1`
|
|
956
|
+
})
|
|
957
|
+
});
|
|
958
|
+
expect(updateRes.status).toBe(200);
|
|
959
|
+
const updated = await updateRes.json() as {
|
|
960
|
+
provider: { id: string; name: string; models: string[]; keyRef?: { kind: string; name: string; configured: boolean }; apiKey?: string };
|
|
961
|
+
};
|
|
962
|
+
expect(updated.provider).toMatchObject({
|
|
963
|
+
id: "local-edit",
|
|
964
|
+
name: "Local Edited",
|
|
965
|
+
models: ["gpt-local-edited", "gpt-local-edited-fast"],
|
|
966
|
+
keyRef: { kind: "secret", name: "local:local-edit", configured: true }
|
|
967
|
+
});
|
|
968
|
+
expect(updated.provider.apiKey).toBeUndefined();
|
|
969
|
+
expect(JSON.stringify(updated)).not.toContain(providerKey);
|
|
970
|
+
} finally {
|
|
971
|
+
await closeServer(provider.server);
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it("blocks local manual provider creation when model probing fails", async () => {
|
|
976
|
+
const providerKey = "provider-local-bad-key";
|
|
977
|
+
const provider = await startJsonServer((_req, res) => {
|
|
978
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
979
|
+
res.end(JSON.stringify({ error: { message: "unauthorized" } }));
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
try {
|
|
983
|
+
const createRes = await fetch(controlUrl("/routing/manual-providers/local"), {
|
|
984
|
+
method: "POST",
|
|
985
|
+
headers: { "content-type": "application/json" },
|
|
986
|
+
body: JSON.stringify({
|
|
987
|
+
id: "local-bad",
|
|
988
|
+
name: "Local Bad",
|
|
989
|
+
baseUrl: `${provider.url}/v1`,
|
|
990
|
+
apiKey: providerKey
|
|
991
|
+
})
|
|
992
|
+
});
|
|
993
|
+
expect(createRes.status).toBe(400);
|
|
994
|
+
const body = await createRes.json() as { error: { code: string; message: string } };
|
|
995
|
+
expect(body.error.code).toBe("manual_provider_local_create_failed");
|
|
996
|
+
expect(body.error.message).toContain("authentication failed");
|
|
997
|
+
expect(body.error.message).not.toContain(providerKey);
|
|
998
|
+
|
|
999
|
+
const listRes = await fetch(controlUrl("/routing/manual-providers"));
|
|
1000
|
+
const listBody = await listRes.json() as { providers: unknown[] };
|
|
1001
|
+
expect(listBody.providers).toEqual([]);
|
|
1002
|
+
} finally {
|
|
1003
|
+
await closeServer(provider.server);
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it("saves disabled auto provider drafts without payment but gates enabled configs", async () => {
|
|
1008
|
+
const draftRes = await fetch(controlUrl("/routing/auto-provider"), {
|
|
1009
|
+
method: "PUT",
|
|
1010
|
+
headers: { "content-type": "application/json" },
|
|
1011
|
+
body: JSON.stringify({
|
|
1012
|
+
enabled: false,
|
|
1013
|
+
range: "custom",
|
|
1014
|
+
scorer: "discount",
|
|
1015
|
+
modelIds: ["gpt-4o"],
|
|
1016
|
+
sellerIds: ["tbs-86d81e"]
|
|
1017
|
+
})
|
|
1018
|
+
});
|
|
1019
|
+
expect(draftRes.status).toBe(200);
|
|
1020
|
+
const draft = await draftRes.json() as { config: { enabled: boolean; range: string; maxConcurrentProviders: number }; paymentRequired: boolean };
|
|
1021
|
+
expect(draft.config.enabled).toBe(false);
|
|
1022
|
+
expect(draft.config.range).toBe("custom");
|
|
1023
|
+
expect(draft.config.maxConcurrentProviders).toBe(10);
|
|
1024
|
+
expect(draft.paymentRequired).toBe(true);
|
|
1025
|
+
|
|
1026
|
+
const blockedRes = await fetch(controlUrl("/routing/auto-provider"), {
|
|
1027
|
+
method: "PUT",
|
|
1028
|
+
headers: { "content-type": "application/json" },
|
|
1029
|
+
body: JSON.stringify({
|
|
1030
|
+
enabled: true,
|
|
1031
|
+
range: "recommended",
|
|
1032
|
+
scorer: "balanced",
|
|
1033
|
+
modelIds: ["gpt-4o"]
|
|
1034
|
+
})
|
|
1035
|
+
});
|
|
1036
|
+
expect(blockedRes.status).toBe(409);
|
|
1037
|
+
const blocked = await blockedRes.json() as { error: { code: string } };
|
|
1038
|
+
expect(blocked.error.code).toBe("payment_required");
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it("applies enabled auto provider configs to the active routing strategy", async () => {
|
|
1042
|
+
markClawtipReady();
|
|
1043
|
+
|
|
1044
|
+
const emptyCustomRes = await fetch(controlUrl("/routing/auto-provider"), {
|
|
1045
|
+
method: "PUT",
|
|
1046
|
+
headers: { "content-type": "application/json" },
|
|
1047
|
+
body: JSON.stringify({
|
|
1048
|
+
enabled: true,
|
|
1049
|
+
range: "custom",
|
|
1050
|
+
scorer: "balanced",
|
|
1051
|
+
modelIds: ["gpt-4o"],
|
|
1052
|
+
sellerIds: []
|
|
1053
|
+
})
|
|
1054
|
+
});
|
|
1055
|
+
expect(emptyCustomRes.status).toBe(200);
|
|
1056
|
+
const emptyCustom = await emptyCustomRes.json() as { config: { enabled: boolean; range: string; sellerIds: string[] }; autoEnabled: boolean; strategy?: unknown };
|
|
1057
|
+
expect(emptyCustom.config).toMatchObject({ enabled: false, range: "custom", sellerIds: [] });
|
|
1058
|
+
expect(emptyCustom.autoEnabled).toBe(false);
|
|
1059
|
+
expect(emptyCustom.strategy).toBeUndefined();
|
|
1060
|
+
|
|
1061
|
+
const emptyCustomModeRes = await fetch(controlUrl("/routing/provider-mode"), {
|
|
1062
|
+
method: "PUT",
|
|
1063
|
+
headers: { "content-type": "application/json" },
|
|
1064
|
+
body: JSON.stringify({ mode: "auto" })
|
|
1065
|
+
});
|
|
1066
|
+
expect(emptyCustomModeRes.status).toBe(200);
|
|
1067
|
+
const emptyCustomMode = await emptyCustomModeRes.json() as { autoEnabled: boolean; strategy?: unknown };
|
|
1068
|
+
expect(emptyCustomMode.autoEnabled).toBe(false);
|
|
1069
|
+
expect(emptyCustomMode.strategy).toBeUndefined();
|
|
1070
|
+
|
|
1071
|
+
const recommendedRes = await fetch(controlUrl("/routing/auto-provider"), {
|
|
1072
|
+
method: "PUT",
|
|
1073
|
+
headers: { "content-type": "application/json" },
|
|
1074
|
+
body: JSON.stringify({
|
|
1075
|
+
enabled: true,
|
|
1076
|
+
range: "recommended",
|
|
1077
|
+
scorer: "speed",
|
|
1078
|
+
modelIds: ["gpt-4o"]
|
|
1079
|
+
})
|
|
1080
|
+
});
|
|
1081
|
+
expect(recommendedRes.status).toBe(200);
|
|
1082
|
+
const recommended = await recommendedRes.json() as { strategy: { mode: string; scorer: string }; mode: string; autoEnabled: boolean };
|
|
1083
|
+
expect(recommended.mode).toBe("auto");
|
|
1084
|
+
expect(recommended.autoEnabled).toBe(true);
|
|
1085
|
+
expect(recommended.strategy).toMatchObject({ mode: "fullAuto", scorer: "speed" });
|
|
1086
|
+
|
|
1087
|
+
const customRes = await fetch(controlUrl("/routing/auto-provider"), {
|
|
1088
|
+
method: "PUT",
|
|
1089
|
+
headers: { "content-type": "application/json" },
|
|
1090
|
+
body: JSON.stringify({
|
|
1091
|
+
enabled: true,
|
|
1092
|
+
range: "custom",
|
|
1093
|
+
scorer: "discount",
|
|
1094
|
+
modelIds: ["gpt-4o"],
|
|
1095
|
+
sellerIds: ["tbs-86d81e", "tbs-719577"]
|
|
1096
|
+
})
|
|
1097
|
+
});
|
|
1098
|
+
expect(customRes.status).toBe(200);
|
|
1099
|
+
const custom = await customRes.json() as { strategy: { mode: string; scorer: string; sellerIds?: string[] } };
|
|
1100
|
+
expect(custom.strategy).toMatchObject({
|
|
1101
|
+
mode: "fixedSet",
|
|
1102
|
+
scorer: "discount",
|
|
1103
|
+
sellerIds: ["tbs-86d81e", "tbs-719577"]
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
const strategyRes = await fetch(controlUrl("/routing/strategy"));
|
|
1107
|
+
const strategy = await strategyRes.json() as { strategy: { mode: string; scorer: string; sellerIds?: string[] } };
|
|
1108
|
+
expect(strategy.strategy).toMatchObject({
|
|
1109
|
+
mode: "fixedSet",
|
|
1110
|
+
scorer: "discount",
|
|
1111
|
+
sellerIds: ["tbs-86d81e", "tbs-719577"]
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it("routes manual provider requests with ordered fallback and records local observations", async () => {
|
|
1116
|
+
const previousA = process.env.TB_TEST_PROVIDER_A_KEY;
|
|
1117
|
+
const previousB = process.env.TB_TEST_PROVIDER_B_KEY;
|
|
1118
|
+
process.env.TB_TEST_PROVIDER_A_KEY = "provider-a-test-key";
|
|
1119
|
+
process.env.TB_TEST_PROVIDER_B_KEY = "provider-b-test-key";
|
|
1120
|
+
|
|
1121
|
+
const providerA = await startJsonServer((req, res) => {
|
|
1122
|
+
expect(req.headers.authorization).toBe("Bearer provider-a-test-key");
|
|
1123
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
1124
|
+
res.end(JSON.stringify({ error: { message: "temporary upstream failure" } }));
|
|
1125
|
+
});
|
|
1126
|
+
const providerB = await startJsonServer((req, res) => {
|
|
1127
|
+
expect(req.headers.authorization).toBe("Bearer provider-b-test-key");
|
|
1128
|
+
let body = "";
|
|
1129
|
+
req.on("data", (chunk) => {
|
|
1130
|
+
body += chunk.toString("utf8");
|
|
1131
|
+
});
|
|
1132
|
+
req.on("end", () => {
|
|
1133
|
+
const parsed = JSON.parse(body) as { model: string };
|
|
1134
|
+
expect(parsed.model).toBe("gpt-manual");
|
|
1135
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1136
|
+
res.end(JSON.stringify({
|
|
1137
|
+
id: "chatcmpl-manual",
|
|
1138
|
+
object: "chat.completion",
|
|
1139
|
+
choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
|
|
1140
|
+
usage: { prompt_tokens: 3, completion_tokens: 2 }
|
|
1141
|
+
}));
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
try {
|
|
1146
|
+
for (const provider of [
|
|
1147
|
+
{ id: "local-a", name: "Local A", baseUrl: `${providerA.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_A_KEY" },
|
|
1148
|
+
{ id: "local-b", name: "Local B", baseUrl: `${providerB.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_B_KEY" }
|
|
1149
|
+
]) {
|
|
1150
|
+
const createRes = await fetch(controlUrl("/routing/manual-providers"), {
|
|
1151
|
+
method: "POST",
|
|
1152
|
+
headers: { "content-type": "application/json" },
|
|
1153
|
+
body: JSON.stringify({
|
|
1154
|
+
...provider,
|
|
1155
|
+
models: ["gpt-manual"],
|
|
1156
|
+
supportedProtocols: ["chat_completions"],
|
|
1157
|
+
enabled: true
|
|
1158
|
+
})
|
|
1159
|
+
});
|
|
1160
|
+
expect(createRes.status).toBe(201);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const modelsRes = await fetch(proxyUrl("/v1/models"));
|
|
1164
|
+
expect(modelsRes.status).toBe(200);
|
|
1165
|
+
const models = await modelsRes.json() as { data: Array<{ id: string; sellerId: string; paymentMethods: string[] }> };
|
|
1166
|
+
expect(models.data.map((model) => [model.id, model.sellerId, model.paymentMethods[0]])).toEqual([
|
|
1167
|
+
["gpt-manual", "local-a", "provider_key"],
|
|
1168
|
+
["gpt-manual", "local-b", "provider_key"]
|
|
1169
|
+
]);
|
|
1170
|
+
|
|
1171
|
+
const response = await fetch(proxyUrl("/v1/chat/completions"), {
|
|
1172
|
+
method: "POST",
|
|
1173
|
+
headers: { "content-type": "application/json" },
|
|
1174
|
+
body: JSON.stringify({
|
|
1175
|
+
model: "gpt-manual",
|
|
1176
|
+
messages: [{ role: "user", content: "hello" }]
|
|
1177
|
+
})
|
|
1178
|
+
});
|
|
1179
|
+
expect(response.status).toBe(200);
|
|
1180
|
+
const body = await response.json() as { choices: unknown[] };
|
|
1181
|
+
expect(body.choices.length).toBe(1);
|
|
1182
|
+
|
|
1183
|
+
const purchases = await (await fetch(controlUrl("/ledger/purchases"))).json() as { purchases: unknown[] };
|
|
1184
|
+
expect(purchases.purchases).toEqual([]);
|
|
1185
|
+
const inferences = await (await fetch(controlUrl("/ledger/inferences"))).json() as {
|
|
1186
|
+
inferences: Array<{ sellerKey: string; paymentMethod?: string; priceVersion?: string; routeReason?: string; fallbackCount?: number }>;
|
|
1187
|
+
};
|
|
1188
|
+
expect(inferences.inferences).toHaveLength(1);
|
|
1189
|
+
expect(inferences.inferences[0]).toMatchObject({
|
|
1190
|
+
sellerKey: "local-b",
|
|
1191
|
+
paymentMethod: "provider_key",
|
|
1192
|
+
priceVersion: "local-provider:local-b",
|
|
1193
|
+
routeReason: "manual:fallback:routes_2",
|
|
1194
|
+
fallbackCount: 1
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
const providersRes = await fetch(controlUrl("/routing/manual-providers"));
|
|
1198
|
+
const providersBody = await providersRes.json() as {
|
|
1199
|
+
providers: Array<{ id: string; current?: boolean; lastAccess?: string; status?: string; errorClass?: string }>;
|
|
1200
|
+
};
|
|
1201
|
+
expect(providersBody.providers).toEqual([
|
|
1202
|
+
expect.objectContaining({ id: "local-a", current: false, status: "degraded", errorClass: "upstream_5xx" }),
|
|
1203
|
+
expect.objectContaining({ id: "local-b", current: true, status: "healthy" })
|
|
1204
|
+
]);
|
|
1205
|
+
expect(providersBody.providers[0].lastAccess).toBeTruthy();
|
|
1206
|
+
expect(providersBody.providers[1].lastAccess).toBeTruthy();
|
|
1207
|
+
} finally {
|
|
1208
|
+
await closeServer(providerA.server);
|
|
1209
|
+
await closeServer(providerB.server);
|
|
1210
|
+
if (previousA === undefined) {
|
|
1211
|
+
delete process.env.TB_TEST_PROVIDER_A_KEY;
|
|
1212
|
+
} else {
|
|
1213
|
+
process.env.TB_TEST_PROVIDER_A_KEY = previousA;
|
|
1214
|
+
}
|
|
1215
|
+
if (previousB === undefined) {
|
|
1216
|
+
delete process.env.TB_TEST_PROVIDER_B_KEY;
|
|
1217
|
+
} else {
|
|
1218
|
+
process.env.TB_TEST_PROVIDER_B_KEY = previousB;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
it("locks manual routing to one provider without falling back", async () => {
|
|
1224
|
+
const previousA = process.env.TB_TEST_PROVIDER_LOCK_A_KEY;
|
|
1225
|
+
const previousB = process.env.TB_TEST_PROVIDER_LOCK_B_KEY;
|
|
1226
|
+
process.env.TB_TEST_PROVIDER_LOCK_A_KEY = "provider-lock-a-key";
|
|
1227
|
+
process.env.TB_TEST_PROVIDER_LOCK_B_KEY = "provider-lock-b-key";
|
|
1228
|
+
let providerBHits = 0;
|
|
1229
|
+
|
|
1230
|
+
const providerA = await startJsonServer((req, res) => {
|
|
1231
|
+
expect(req.headers.authorization).toBe("Bearer provider-lock-a-key");
|
|
1232
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
1233
|
+
res.end(JSON.stringify({ error: { message: "locked upstream failed" } }));
|
|
1234
|
+
});
|
|
1235
|
+
const providerB = await startJsonServer((req, res) => {
|
|
1236
|
+
providerBHits += 1;
|
|
1237
|
+
expect(req.headers.authorization).toBe("Bearer provider-lock-b-key");
|
|
1238
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1239
|
+
res.end(JSON.stringify({
|
|
1240
|
+
id: "chatcmpl-should-not-use-backup",
|
|
1241
|
+
object: "chat.completion",
|
|
1242
|
+
choices: [{ index: 0, message: { role: "assistant", content: "backup" }, finish_reason: "stop" }],
|
|
1243
|
+
usage: { prompt_tokens: 1, completion_tokens: 1 }
|
|
1244
|
+
}));
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
try {
|
|
1248
|
+
for (const provider of [
|
|
1249
|
+
{ id: "lock-a", name: "Lock A", baseUrl: `${providerA.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_LOCK_A_KEY" },
|
|
1250
|
+
{ id: "lock-b", name: "Lock B", baseUrl: `${providerB.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_LOCK_B_KEY" }
|
|
1251
|
+
]) {
|
|
1252
|
+
const createRes = await fetch(controlUrl("/routing/manual-providers"), {
|
|
1253
|
+
method: "POST",
|
|
1254
|
+
headers: { "content-type": "application/json" },
|
|
1255
|
+
body: JSON.stringify({
|
|
1256
|
+
...provider,
|
|
1257
|
+
models: ["gpt-locked"],
|
|
1258
|
+
supportedProtocols: ["chat_completions"],
|
|
1259
|
+
enabled: true
|
|
1260
|
+
})
|
|
1261
|
+
});
|
|
1262
|
+
expect(createRes.status).toBe(201);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const lockRes = await fetch(controlUrl("/routing/manual-providers/routing"), {
|
|
1266
|
+
method: "PUT",
|
|
1267
|
+
headers: { "content-type": "application/json" },
|
|
1268
|
+
body: JSON.stringify({ policy: "locked", lockedProviderId: "lock-a" })
|
|
1269
|
+
});
|
|
1270
|
+
expect(lockRes.status).toBe(200);
|
|
1271
|
+
const lockBody = await lockRes.json() as { routing: { policy: string; lockedProviderId?: string } };
|
|
1272
|
+
expect(lockBody.routing).toEqual({ policy: "locked", lockedProviderId: "lock-a" });
|
|
1273
|
+
|
|
1274
|
+
const response = await fetch(proxyUrl("/v1/chat/completions"), {
|
|
1275
|
+
method: "POST",
|
|
1276
|
+
headers: { "content-type": "application/json" },
|
|
1277
|
+
body: JSON.stringify({
|
|
1278
|
+
model: "gpt-locked",
|
|
1279
|
+
messages: [{ role: "user", content: "hello" }]
|
|
1280
|
+
})
|
|
1281
|
+
});
|
|
1282
|
+
expect(response.status).toBe(500);
|
|
1283
|
+
expect(providerBHits).toBe(0);
|
|
1284
|
+
|
|
1285
|
+
const providersRes = await fetch(controlUrl("/routing/manual-providers"));
|
|
1286
|
+
const providersBody = await providersRes.json() as {
|
|
1287
|
+
routing: { policy: string; lockedProviderId?: string };
|
|
1288
|
+
providers: Array<{ id: string; current?: boolean; status?: string; errorClass?: string }>;
|
|
1289
|
+
};
|
|
1290
|
+
expect(providersBody.routing).toEqual({ policy: "locked", lockedProviderId: "lock-a" });
|
|
1291
|
+
expect(providersBody.providers).toEqual([
|
|
1292
|
+
expect.objectContaining({ id: "lock-a", current: false, status: "degraded", errorClass: "upstream_5xx" }),
|
|
1293
|
+
expect.objectContaining({ id: "lock-b" })
|
|
1294
|
+
]);
|
|
1295
|
+
|
|
1296
|
+
const fallbackRes = await fetch(controlUrl("/routing/manual-providers/routing"), {
|
|
1297
|
+
method: "PUT",
|
|
1298
|
+
headers: { "content-type": "application/json" },
|
|
1299
|
+
body: JSON.stringify({ policy: "fallback" })
|
|
1300
|
+
});
|
|
1301
|
+
expect(fallbackRes.status).toBe(200);
|
|
1302
|
+
const fallbackBody = await fallbackRes.json() as { routing: { policy: string; lockedProviderId?: string } };
|
|
1303
|
+
expect(fallbackBody.routing).toEqual({ policy: "fallback" });
|
|
1304
|
+
} finally {
|
|
1305
|
+
await closeServer(providerA.server);
|
|
1306
|
+
await closeServer(providerB.server);
|
|
1307
|
+
if (previousA === undefined) {
|
|
1308
|
+
delete process.env.TB_TEST_PROVIDER_LOCK_A_KEY;
|
|
1309
|
+
} else {
|
|
1310
|
+
process.env.TB_TEST_PROVIDER_LOCK_A_KEY = previousA;
|
|
1311
|
+
}
|
|
1312
|
+
if (previousB === undefined) {
|
|
1313
|
+
delete process.env.TB_TEST_PROVIDER_LOCK_B_KEY;
|
|
1314
|
+
} else {
|
|
1315
|
+
process.env.TB_TEST_PROVIDER_LOCK_B_KEY = previousB;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
it("reorders manual provider fallback priority", async () => {
|
|
1321
|
+
const previousA = process.env.TB_TEST_PROVIDER_ORDER_A_KEY;
|
|
1322
|
+
const previousB = process.env.TB_TEST_PROVIDER_ORDER_B_KEY;
|
|
1323
|
+
process.env.TB_TEST_PROVIDER_ORDER_A_KEY = "provider-order-a-key";
|
|
1324
|
+
process.env.TB_TEST_PROVIDER_ORDER_B_KEY = "provider-order-b-key";
|
|
1325
|
+
let providerAHits = 0;
|
|
1326
|
+
let providerBHits = 0;
|
|
1327
|
+
|
|
1328
|
+
const providerA = await startJsonServer((req, res) => {
|
|
1329
|
+
providerAHits += 1;
|
|
1330
|
+
expect(req.headers.authorization).toBe("Bearer provider-order-a-key");
|
|
1331
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1332
|
+
res.end(JSON.stringify({
|
|
1333
|
+
id: "chatcmpl-order-a",
|
|
1334
|
+
object: "chat.completion",
|
|
1335
|
+
choices: [{ index: 0, message: { role: "assistant", content: "a" }, finish_reason: "stop" }],
|
|
1336
|
+
usage: { prompt_tokens: 1, completion_tokens: 1 }
|
|
1337
|
+
}));
|
|
1338
|
+
});
|
|
1339
|
+
const providerB = await startJsonServer((req, res) => {
|
|
1340
|
+
providerBHits += 1;
|
|
1341
|
+
expect(req.headers.authorization).toBe("Bearer provider-order-b-key");
|
|
1342
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1343
|
+
res.end(JSON.stringify({
|
|
1344
|
+
id: "chatcmpl-order-b",
|
|
1345
|
+
object: "chat.completion",
|
|
1346
|
+
choices: [{ index: 0, message: { role: "assistant", content: "b" }, finish_reason: "stop" }],
|
|
1347
|
+
usage: { prompt_tokens: 1, completion_tokens: 1 }
|
|
1348
|
+
}));
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
try {
|
|
1352
|
+
for (const provider of [
|
|
1353
|
+
{ id: "order-a", name: "Order A", baseUrl: `${providerA.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_ORDER_A_KEY" },
|
|
1354
|
+
{ id: "order-b", name: "Order B", baseUrl: `${providerB.url}/v1`, apiKeyEnv: "TB_TEST_PROVIDER_ORDER_B_KEY" }
|
|
1355
|
+
]) {
|
|
1356
|
+
const createRes = await fetch(controlUrl("/routing/manual-providers"), {
|
|
1357
|
+
method: "POST",
|
|
1358
|
+
headers: { "content-type": "application/json" },
|
|
1359
|
+
body: JSON.stringify({
|
|
1360
|
+
...provider,
|
|
1361
|
+
models: ["gpt-order"],
|
|
1362
|
+
supportedProtocols: ["chat_completions"],
|
|
1363
|
+
enabled: true
|
|
1364
|
+
})
|
|
1365
|
+
});
|
|
1366
|
+
expect(createRes.status).toBe(201);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const orderRes = await fetch(controlUrl("/routing/manual-providers/order"), {
|
|
1370
|
+
method: "PUT",
|
|
1371
|
+
headers: { "content-type": "application/json" },
|
|
1372
|
+
body: JSON.stringify({ providerIds: ["order-b", "order-a"] })
|
|
1373
|
+
});
|
|
1374
|
+
expect(orderRes.status).toBe(200);
|
|
1375
|
+
const orderBody = await orderRes.json() as { providers: Array<{ id: string }> };
|
|
1376
|
+
expect(orderBody.providers.map((provider) => provider.id)).toEqual(["order-b", "order-a"]);
|
|
1377
|
+
|
|
1378
|
+
const response = await fetch(proxyUrl("/v1/chat/completions"), {
|
|
1379
|
+
method: "POST",
|
|
1380
|
+
headers: { "content-type": "application/json" },
|
|
1381
|
+
body: JSON.stringify({
|
|
1382
|
+
model: "gpt-order",
|
|
1383
|
+
messages: [{ role: "user", content: "hello" }]
|
|
1384
|
+
})
|
|
1385
|
+
});
|
|
1386
|
+
expect(response.status).toBe(200);
|
|
1387
|
+
expect(providerBHits).toBe(1);
|
|
1388
|
+
expect(providerAHits).toBe(0);
|
|
1389
|
+
} finally {
|
|
1390
|
+
await closeServer(providerA.server);
|
|
1391
|
+
await closeServer(providerB.server);
|
|
1392
|
+
if (previousA === undefined) {
|
|
1393
|
+
delete process.env.TB_TEST_PROVIDER_ORDER_A_KEY;
|
|
1394
|
+
} else {
|
|
1395
|
+
process.env.TB_TEST_PROVIDER_ORDER_A_KEY = previousA;
|
|
1396
|
+
}
|
|
1397
|
+
if (previousB === undefined) {
|
|
1398
|
+
delete process.env.TB_TEST_PROVIDER_ORDER_B_KEY;
|
|
1399
|
+
} else {
|
|
1400
|
+
process.env.TB_TEST_PROVIDER_ORDER_B_KEY = previousB;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
});
|
|
1405
|
+
|
|
630
1406
|
// ─── GET /routing/strategy ────────────────────────────────────
|
|
631
1407
|
describe("GET /routing/strategy", () => {
|
|
632
1408
|
it("returns default strategy with source=default when no store value", async () => {
|