summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-24 04:26:40 +0900
committerAdam Malczewski <[email protected]>2026-06-24 04:26:40 +0900
commitf2e452bbebc7d99d1ae9ba74b32334b85af7902d (patch)
treecc5052d574c05123ce930a09379a7d0a24d9a660 /packages/transport-http
parent13eb34133d8fe64f9c73f8d394e0af790b54c6e5 (diff)
downloaddispatch-f2e452bbebc7d99d1ae9ba74b32334b85af7902d.tar.gz
dispatch-f2e452bbebc7d99d1ae9ba74b32334b85af7902d.zip
feat: persistent per-conversation model selection
A chat's selected provider + model is now persisted per conversation (like cwd and reasoningEffort). Opening a conversation in a new browser recalls the originally selected model instead of defaulting. - transport-contract 0.19.0→0.20.0: ModelResponse + SetModelRequest types for GET/PUT /conversations/:id/model. - conversation-store: getModel/setModel (model:<id> key, mirrors getReasoningEffort/setReasoningEffort); forkHistory copies model; empty string clears. - session-orchestrator: resolve model from persisted store when no per-turn override; persist the resolved model so it sticks; warm path parity. - transport-http: GET/PUT /conversations/:id/model endpoints with validation. 1433 vitest pass; tsc + biome clean.
Diffstat (limited to 'packages/transport-http')
-rw-r--r--packages/transport-http/src/app.test.ts227
-rw-r--r--packages/transport-http/src/app.ts53
-rw-r--r--packages/transport-http/src/logic.ts27
3 files changed, 307 insertions, 0 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 7887695..153a63a 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -98,6 +98,7 @@ function createFakeConversationStore(
metricsStore: Map<string, TurnMetrics[]> = new Map(),
cwdStore: Map<string, string> = new Map(),
reasoningEffortStore: Map<string, ReasoningEffort> = new Map(),
+ modelStore: Map<string, string> = new Map(),
): ConversationStore {
return {
async append() {},
@@ -137,6 +138,16 @@ function createFakeConversationStore(
async setReasoningEffort(conversationId, effort) {
reasoningEffortStore.set(conversationId, effort);
},
+ async getModel(conversationId) {
+ return modelStore.get(conversationId) ?? null;
+ },
+ async setModel(conversationId, model) {
+ if (model === "") {
+ modelStore.delete(conversationId);
+ } else {
+ modelStore.set(conversationId, model);
+ }
+ },
async listConversations() {
return [];
},
@@ -2615,6 +2626,222 @@ describe("PUT /conversations/:id/reasoning-effort", () => {
});
});
+describe("GET /conversations/:id/model", () => {
+ it("returns null when never set", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.model).toBeNull();
+ });
+
+ it("returns the model after PUT", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
+ });
+
+ const res = await app.request("/conversations/conv1/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBe("umans/umans-glm-5.2");
+ });
+
+ it("returns null for an unknown conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/unknown/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("unknown");
+ expect(body.model).toBeNull();
+ });
+});
+
+describe("PUT /conversations/:id/model", () => {
+ it("persists a non-empty model and returns it", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.model).toBe("umans/umans-glm-5.2");
+
+ // A subsequent GET reflects the persisted value.
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBe("umans/umans-glm-5.2");
+ });
+
+ it("clears the model when model is null and GET returns null", async () => {
+ const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
+ const store = createFakeConversationStore(
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ modelStore,
+ );
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ // Preconditions: a model is set.
+ const before = await app.request("/conversations/conv1/model");
+ expect(((await before.json()) as { model: string | null }).model).toBe("umans/umans-glm-5.2");
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: null }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBeNull();
+ });
+
+ it("clears the model when model is an empty string and GET returns null", async () => {
+ const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
+ const store = createFakeConversationStore(
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ modelStore,
+ );
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBeNull();
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when model field is missing", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("model");
+ });
+
+ it("returns 400 when model is a non-string non-null type", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: 42 }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("does not call store on validation failure", async () => {
+ let storeCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setModel() {
+ storeCalled = true;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(storeCalled).toBe(false);
+ });
+});
+
describe("GET /conversations", () => {
const sampleConvos: ConversationMeta[] = [
{
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index bc8b9de..7fdbb00 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -13,6 +13,7 @@ import type {
LastMessageResponse,
LspServerInfo,
LspStatusResponse,
+ ModelResponse,
ModelsResponse,
OpenConversationResponse,
QueueResponse,
@@ -33,11 +34,13 @@ import {
computeCachePct,
computeExpectedCacheRate,
extractLastAssistantText,
+ isModelParseError,
isParseError,
isReasoningEffortParseError,
isSinceSeqError,
isWindowParamError,
parseChatBody,
+ parseModelBody,
parseQueueBody,
parseReasoningEffortBody,
parseSinceSeq,
@@ -613,6 +616,56 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ app.get("/conversations/:id/model", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const model = await opts.conversationStore.getModel(conversationId);
+ log.info("conversations: model read", {
+ conversationId,
+ hasModel: model !== null,
+ });
+ const body: ModelResponse = { conversationId, model };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: model read failure", { err });
+ return c.json({ error: "Failed to read conversation model" }, 500);
+ }
+ });
+
+ app.put("/conversations/:id/model", async (c) => {
+ const conversationId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("conversations/model: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ const parsed = parseModelBody(body);
+ if (isModelParseError(parsed)) {
+ log.warn("conversations/model: validation failed", { reason: parsed.error });
+ return c.json({ error: parsed.error }, 400);
+ }
+
+ // A non-null non-empty model persists the selection; `null` or an empty
+ // string clears the key (the store treats an empty string as "delete").
+ // The response carries the resulting value: the model name, or null when
+ // cleared (mirroring how `getModel` returns null after a clear).
+ const resultModel = parsed !== null && parsed.length > 0 ? parsed : null;
+ const persistedValue = resultModel !== null ? resultModel : "";
+
+ try {
+ await opts.conversationStore.setModel(conversationId, persistedValue);
+ log.debug("conversations: model set", { conversationId, model: resultModel });
+ const response: ModelResponse = { conversationId, model: resultModel };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: model set failure", { err });
+ return c.json({ error: "Failed to set conversation model" }, 500);
+ }
+ });
+
app.get("/conversations/:id/lsp", async (c) => {
const conversationId = c.req.param("id");
try {
diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts
index 843aeb8..948afb8 100644
--- a/packages/transport-http/src/logic.ts
+++ b/packages/transport-http/src/logic.ts
@@ -270,6 +270,33 @@ export function isReasoningEffortParseError(
}
/**
+ * Parse + validate a `PUT /conversations/:id/model` body (`SetModelRequest`).
+ * `model` must be present and either a string (any value, including the empty
+ * string — which clears the persisted selection) or `null`. A missing field or
+ * a non-string/non-null value → {@link ParseError}. There is no enum
+ * validation (the provider resolves model names at turn time).
+ *
+ * Returns the validated `model` value (`string | null`) on success.
+ */
+export function parseModelBody(body: unknown): string | null | ParseError {
+ if (body === null || typeof body !== "object") {
+ return { error: "Request body must be a JSON object" };
+ }
+ const obj = body as Record<string, unknown>;
+ if (obj.model === undefined) {
+ return { error: "Field 'model' is required and must be a string or null" };
+ }
+ if (obj.model !== null && typeof obj.model !== "string") {
+ return { error: "Field 'model' must be a string or null" };
+ }
+ return obj.model as string | null;
+}
+
+export function isModelParseError(result: string | null | ParseError): result is ParseError {
+ return typeof result === "object" && result !== null && "error" in result;
+}
+
+/**
* Extract the text of the last assistant message's last `text` chunk — the
* "show me the last reply" affordance for `GET /conversations/:id/last`.
*