Skip to content

Commit 7f9161c

Browse files
fix session routes for removed providers
1 parent 4780546 commit 7f9161c

4 files changed

Lines changed: 678 additions & 36 deletions

File tree

src/gateway/server-methods/chat.ts

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ import {
148148
readRecentSessionMessagesAsync,
149149
resolveSessionModelRef,
150150
resolveSessionStoreKey,
151+
shouldLoadModelCatalogForSessionModelResolution,
151152
} from "../session-utils.js";
152153
import { formatForLog } from "../ws-log.js";
153154
import { setGatewayDedupeEntry } from "./agent-wait-dedupe.js";
@@ -2992,7 +2993,43 @@ export const chatHandlers: GatewayRequestHandlers = {
29922993
agentId: selectedAgent.agentId,
29932994
mainKey: cfg.session?.mainKey,
29942995
});
2995-
const resolvedSessionModel = resolveSessionModelRef(cfg, entry, agentId);
2996+
if (stopCommand) {
2997+
const defaultAgentId = resolveDefaultAgentId(cfg);
2998+
const stopAgentId =
2999+
sessionKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId;
3000+
const res = await abortChatRunsForSessionKeyWithPartials({
3001+
context,
3002+
ops: createChatAbortOps(context),
3003+
sessionKey: rawSessionKey,
3004+
sessionKeyAliases: sessionKey === rawSessionKey ? undefined : [sessionKey],
3005+
agentId: stopAgentId,
3006+
sessionId: entry?.sessionId,
3007+
persistSessionKey: sessionKey,
3008+
defaultAgentId,
3009+
abortOrigin: "stop-command",
3010+
stopReason: "stop",
3011+
requester: resolveChatAbortRequester(client),
3012+
});
3013+
if (res.unauthorized) {
3014+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"));
3015+
return;
3016+
}
3017+
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
3018+
return;
3019+
}
3020+
const sessionModelCatalog = shouldLoadModelCatalogForSessionModelResolution(cfg, entry, agentId)
3021+
? await measureDiagnosticsTimelineSpan(
3022+
"gateway.chat_send.session_model_catalog",
3023+
async () => context.loadGatewayModelCatalog().catch(() => undefined),
3024+
{
3025+
config: cfg,
3026+
phase: "chat.send",
3027+
},
3028+
)
3029+
: undefined;
3030+
const resolvedSessionModel = resolveSessionModelRef(cfg, entry, agentId, {
3031+
modelCatalog: sessionModelCatalog,
3032+
});
29963033
const resolvedSessionAuthProvider = resolveProviderIdForAuth(resolvedSessionModel.provider, {
29973034
config: cfg,
29983035
});
@@ -3025,31 +3062,6 @@ export const chatHandlers: GatewayRequestHandlers = {
30253062
return;
30263063
}
30273064

3028-
if (stopCommand) {
3029-
const defaultAgentId = resolveDefaultAgentId(cfg);
3030-
const stopAgentId =
3031-
sessionKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId;
3032-
const res = await abortChatRunsForSessionKeyWithPartials({
3033-
context,
3034-
ops: createChatAbortOps(context),
3035-
sessionKey: rawSessionKey,
3036-
sessionKeyAliases: sessionKey === rawSessionKey ? undefined : [sessionKey],
3037-
agentId: stopAgentId,
3038-
sessionId: entry?.sessionId,
3039-
persistSessionKey: sessionKey,
3040-
defaultAgentId,
3041-
abortOrigin: "stop-command",
3042-
stopReason: "stop",
3043-
requester: resolveChatAbortRequester(client),
3044-
});
3045-
if (res.unauthorized) {
3046-
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"));
3047-
return;
3048-
}
3049-
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
3050-
return;
3051-
}
3052-
30533065
const cached = context.dedupe.get(`chat:${clientRunId}`);
30543066
if (cached) {
30553067
respond(cached.ok, cached.payload, cached.error, {

src/gateway/server.chat.gateway-server-chat-b.test.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,17 @@ describe("gateway server chat", () => {
500500
const dispatchRelease = createDeferred<void>();
501501
try {
502502
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
503+
testState.agentConfig = {
504+
model: { primary: "test-provider/vision-model" },
505+
models: {
506+
providers: {
507+
"test-provider": {
508+
api: "openai-completions",
509+
models: [{ id: "vision-model", name: "Vision Model" }],
510+
},
511+
},
512+
},
513+
};
503514
await writeSessionStore({
504515
entries: {
505516
main: {
@@ -629,6 +640,7 @@ describe("gateway server chat", () => {
629640
dispatchRelease.resolve();
630641
dispatchInboundMessageMock.mockReset();
631642
testState.sessionStorePath = undefined;
643+
testState.agentConfig = undefined;
632644
clearConfigCache();
633645
await fs.rm(sessionDir, { recursive: true, force: true });
634646
}
@@ -640,6 +652,17 @@ describe("gateway server chat", () => {
640652
createDeferred<Awaited<ReturnType<GatewayRequestContext["loadGatewayModelCatalog"]>>>();
641653
try {
642654
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
655+
testState.agentConfig = {
656+
model: { primary: "test-provider/vision-model" },
657+
models: {
658+
providers: {
659+
"test-provider": {
660+
api: "openai-completions",
661+
models: [{ id: "vision-model", name: "Vision Model" }],
662+
},
663+
},
664+
},
665+
};
643666
await writeSessionStore({
644667
entries: {
645668
main: {
@@ -819,11 +842,211 @@ describe("gateway server chat", () => {
819842
firstCatalog.resolve([]);
820843
dispatchInboundMessageMock.mockReset();
821844
testState.sessionStorePath = undefined;
845+
testState.agentConfig = undefined;
822846
clearConfigCache();
823847
await fs.rm(sessionDir, { recursive: true, force: true });
824848
}
825849
});
826850

851+
test("chat.send stop command bypasses session model catalog validation", async () => {
852+
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
853+
try {
854+
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
855+
testState.agentConfig = {
856+
model: { primary: "openai/gpt-5.4" },
857+
};
858+
await writeSessionStore({
859+
entries: {
860+
main: {
861+
sessionId: "sess-main",
862+
providerOverride: "custom-api-deepseek-com",
863+
modelOverride: "deepseek-v4-pro",
864+
modelProvider: "custom-api-deepseek-com",
865+
model: "deepseek-v4-pro",
866+
updatedAt: Date.now(),
867+
},
868+
},
869+
});
870+
871+
const context = {
872+
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(
873+
async () => {
874+
throw new Error("stop command must not load model catalog");
875+
},
876+
),
877+
logGateway: {
878+
info: vi.fn(),
879+
warn: vi.fn(),
880+
error: vi.fn(),
881+
debug: vi.fn(),
882+
},
883+
agentRunSeq: new Map<string, number>(),
884+
chatAbortControllers: new Map(),
885+
chatAbortedRuns: new Map(),
886+
chatRunBuffers: new Map(),
887+
chatDeltaSentAt: new Map(),
888+
chatDeltaLastBroadcastLen: new Map(),
889+
chatDeltaLastBroadcastText: new Map(),
890+
agentDeltaSentAt: new Map(),
891+
bufferedAgentEvents: new Map(),
892+
clearChatRunState: vi.fn(),
893+
addChatRun: vi.fn(),
894+
removeChatRun: vi.fn(),
895+
broadcast: vi.fn(),
896+
nodeSendToSession: vi.fn(),
897+
registerToolEventRecipient: vi.fn(),
898+
dedupe: new Map(),
899+
} as unknown as GatewayRequestContext;
900+
const { chatHandlers } = await import("./server-methods/chat.js");
901+
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
902+
903+
await chatHandlers["chat.send"]({
904+
req: {
905+
type: "req",
906+
id: "stop-bypasses-model-catalog",
907+
method: "chat.send",
908+
params: {
909+
sessionKey: "main",
910+
message: "/stop",
911+
idempotencyKey: "idem-stop-bypass-model-catalog",
912+
},
913+
},
914+
params: {
915+
sessionKey: "main",
916+
message: "/stop",
917+
idempotencyKey: "idem-stop-bypass-model-catalog",
918+
},
919+
client: null,
920+
isWebchatConnect: () => false,
921+
respond: ((ok, payload, error) => {
922+
responses.push({ ok, payload, error });
923+
}) as RespondFn,
924+
context,
925+
});
926+
927+
expect(context.loadGatewayModelCatalog).not.toHaveBeenCalled();
928+
expect(responses).toEqual([
929+
{
930+
ok: true,
931+
payload: { ok: true, aborted: false, runIds: [] },
932+
error: undefined,
933+
},
934+
]);
935+
} finally {
936+
testState.sessionStorePath = undefined;
937+
clearConfigCache();
938+
await fs.rm(sessionDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
939+
}
940+
});
941+
942+
test("chat.send fresh custom-provider session does not load model catalog", async () => {
943+
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
944+
try {
945+
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
946+
await writeGatewayConfig({
947+
agents: {
948+
defaults: {
949+
model: { primary: "custom-api-deepseek-com/deepseek-v4-pro" },
950+
},
951+
},
952+
models: {
953+
providers: {
954+
"custom-api-deepseek-com": {
955+
baseUrl: "https://deepseek.example.com/v1",
956+
models: [{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }],
957+
},
958+
},
959+
},
960+
});
961+
await writeSessionStore({
962+
entries: {
963+
main: {
964+
sessionId: "sess-main",
965+
updatedAt: Date.now(),
966+
},
967+
},
968+
});
969+
970+
const context = {
971+
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(
972+
async () => {
973+
throw new Error("fresh custom-provider session must not load model catalog");
974+
},
975+
),
976+
logGateway: {
977+
info: vi.fn(),
978+
warn: vi.fn(),
979+
error: vi.fn(),
980+
debug: vi.fn(),
981+
},
982+
agentRunSeq: new Map<string, number>(),
983+
chatAbortControllers: new Map(),
984+
chatAbortedRuns: new Map(),
985+
chatRunBuffers: new Map(),
986+
chatDeltaSentAt: new Map(),
987+
chatDeltaLastBroadcastLen: new Map(),
988+
chatDeltaLastBroadcastText: new Map(),
989+
agentDeltaSentAt: new Map(),
990+
bufferedAgentEvents: new Map(),
991+
clearChatRunState: vi.fn(),
992+
addChatRun: vi.fn(),
993+
removeChatRun: vi.fn(),
994+
broadcast: vi.fn(),
995+
nodeSendToSession: vi.fn(),
996+
registerToolEventRecipient: vi.fn(),
997+
dedupe: new Map(),
998+
} as unknown as GatewayRequestContext;
999+
dispatchInboundMessageMock.mockResolvedValueOnce(undefined);
1000+
1001+
const { chatHandlers } = await import("./server-methods/chat.js");
1002+
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
1003+
await chatHandlers["chat.send"]({
1004+
req: {
1005+
type: "req",
1006+
id: "fresh-custom-provider-no-catalog",
1007+
method: "chat.send",
1008+
params: {
1009+
sessionKey: "main",
1010+
message: "hello",
1011+
idempotencyKey: "idem-fresh-custom-provider-no-catalog",
1012+
},
1013+
},
1014+
params: {
1015+
sessionKey: "main",
1016+
message: "hello",
1017+
idempotencyKey: "idem-fresh-custom-provider-no-catalog",
1018+
},
1019+
client: null,
1020+
isWebchatConnect: () => false,
1021+
respond: ((ok, payload, error) => {
1022+
responses.push({ ok, payload, error });
1023+
}) as RespondFn,
1024+
context,
1025+
});
1026+
1027+
expect(context.loadGatewayModelCatalog).not.toHaveBeenCalled();
1028+
expect(responses).toEqual([
1029+
{
1030+
ok: true,
1031+
payload: expect.objectContaining({
1032+
runId: "idem-fresh-custom-provider-no-catalog",
1033+
status: "started",
1034+
}),
1035+
error: undefined,
1036+
},
1037+
]);
1038+
await vi.waitFor(() => expect(context.removeChatRun).toHaveBeenCalledTimes(1));
1039+
} finally {
1040+
dispatchInboundMessageMock.mockReset();
1041+
testState.sessionStorePath = undefined;
1042+
clearConfigCache();
1043+
if (process.env.OPENCLAW_CONFIG_PATH) {
1044+
await fs.rm(process.env.OPENCLAW_CONFIG_PATH, { force: true });
1045+
}
1046+
await fs.rm(sessionDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
1047+
}
1048+
});
1049+
8271050
test.each(configuredImageModelCases)(
8281051
"chat.send preserves text-only image uploads as MediaPaths even with configured imageModel: $id",
8291052
async ({ id, imageModel }) => {

0 commit comments

Comments
 (0)