From 26f6e30472e94eb1a6f6d2bda07d23572aaed8c6 Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:06:01 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20feat:=20stamp=20per-workspac?= =?UTF-8?q?e=20OpenRouter=20session=5Fid=20for=20sticky=20routing=20and=20?= =?UTF-8?q?session=20grouping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace requests through the OpenRouter provider carry session_id "mux-" — OpenRouter's explicit sticky-routing key (same upstream provider per session for prompt-cache hits) and the grouping key for its Sessions log view. Explicit config session_id wins verbatim, null disables tagging, and a configured x-session-id header suppresses the stamp. Status and title generation forward workspaceId so their traffic joins the workspace session. Also fixes pre-existing config leakage in the same block: the baseUrl→baseURL rename ran before a destructure that stripped only the stale key, so baseURL, apiKeyFile (a local path), and enabled shipped as junk top-level body fields; config-level extraBody was demoted into a literal nested body field instead of being lifted to the root. Co-Authored-By: Claude Fable 5 --- docs/config/providers.mdx | 6 + src/common/config/schemas/providersConfig.ts | 3 + src/constants/appAttribution.ts | 6 + .../builtInSkillContent.generated.ts | 6 + src/node/services/agentStatusService.ts | 1 + .../services/providerModelFactory.test.ts | 252 ++++++++++++++++++ src/node/services/providerModelFactory.ts | 70 ++++- src/node/services/workspaceService.ts | 12 +- src/node/services/workspaceStatusGenerator.ts | 7 + src/node/services/workspaceTitleGenerator.ts | 9 +- 10 files changed, 365 insertions(+), 7 deletions(-) diff --git a/docs/config/providers.mdx b/docs/config/providers.mdx index 69bcc62e3d9..81d472bf450 100644 --- a/docs/config/providers.mdx +++ b/docs/config/providers.mdx @@ -252,6 +252,12 @@ Control which infrastructure providers handle your requests: See [OpenRouter Provider Routing docs](https://openrouter.ai/docs/features/provider-routing) for details. +### OpenRouter Sessions + +Mux tags workspace requests sent through the OpenRouter provider with a per-workspace `session_id` (`mux-`). OpenRouter uses it as the [sticky-routing key](https://openrouter.ai/docs/guides/best-practices/prompt-caching), keeping a workspace's turns on the same upstream provider to maximize prompt-cache hits, and groups the workspace's requests in the Sessions view of its Logs page. Requests with no workspace context (such as naming a workspace that doesn't exist yet) are not tagged. + +To override the per-workspace value with a static one, set `"session_id": ""` in the provider config; to disable session tagging entirely, set `"session_id": null`. A configured `x-session-id` header also suppresses the per-workspace default. + ### xAI Search Orchestration Grok models support live web search. Mux enables this by default with `mode: "auto"`. Customize via [`searchParameters`](https://docs.x.ai/docs/resources/search) for regional focus, time filters, or to disable search. diff --git a/src/common/config/schemas/providersConfig.ts b/src/common/config/schemas/providersConfig.ts index 26b761b26bf..7451a13af66 100644 --- a/src/common/config/schemas/providersConfig.ts +++ b/src/common/config/schemas/providersConfig.ts @@ -60,6 +60,9 @@ export const OpenRouterProviderConfigSchema = BaseProviderConfigSchema.extend({ data_collection: z.string().optional(), sort: z.string().optional(), quantizations: z.array(z.string()).optional(), + // Static override for the per-workspace session key mux stamps on requests; + // null disables session tagging entirely (nothing is sent). + session_id: z.string().nullable().optional(), }); export const XAIProviderConfigSchema = BaseProviderConfigSchema.extend({ diff --git a/src/constants/appAttribution.ts b/src/constants/appAttribution.ts index d58bc6a8725..7240c5d1a36 100644 --- a/src/constants/appAttribution.ts +++ b/src/constants/appAttribution.ts @@ -5,3 +5,9 @@ export const MUX_APP_ATTRIBUTION_TITLE = "mux"; export const MUX_APP_ATTRIBUTION_URL = "https://mux.coder.com"; + +// Prefix for per-workspace OpenRouter session_id values. Derived from the app +// title so the app's identity strings stay in lockstep; the prefix namespaces +// mux's session keys in the user's account-scoped id space so they cannot +// collide with another tool's session ids. +export const MUX_OPENROUTER_SESSION_ID_PREFIX = `${MUX_APP_ATTRIBUTION_TITLE}-`; diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 0fb29db5fec..e37dd06fd2c 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -3873,6 +3873,12 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "See [OpenRouter Provider Routing docs](https://openrouter.ai/docs/features/provider-routing) for details.", "", + "### OpenRouter Sessions", + "", + "Mux tags workspace requests sent through the OpenRouter provider with a per-workspace `session_id` (`mux-`). OpenRouter uses it as the [sticky-routing key](https://openrouter.ai/docs/guides/best-practices/prompt-caching), keeping a workspace's turns on the same upstream provider to maximize prompt-cache hits, and groups the workspace's requests in the Sessions view of its Logs page. Requests with no workspace context (such as naming a workspace that doesn't exist yet) are not tagged.", + "", + 'To override the per-workspace value with a static one, set `"session_id": ""` in the provider config; to disable session tagging entirely, set `"session_id": null`. A configured `x-session-id` header also suppresses the per-workspace default.', + "", "### xAI Search Orchestration", "", 'Grok models support live web search. Mux enables this by default with `mode: "auto"`. Customize via [`searchParameters`](https://docs.x.ai/docs/resources/search) for regional focus, time filters, or to disable search.', diff --git a/src/node/services/agentStatusService.ts b/src/node/services/agentStatusService.ts index c7b3e5b1625..0410cdd8652 100644 --- a/src/node/services/agentStatusService.ts +++ b/src/node/services/agentStatusService.ts @@ -355,6 +355,7 @@ export class AgentStatusService { // would leak background LLM work past our lifecycle. if (this.stopped) return; const result = await generateWorkspaceStatus(transcript, candidates, this.aiService, { + workspaceId, streaming, recordUsage: async (modelString, usage, usageOptions) => { const recorded = await this.sessionUsageService?.recordHeadlessUsage( diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index fe8505710fe..b70ff2abeb8 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -1542,6 +1542,258 @@ describe("ProviderModelFactory routing", () => { }); }); + describe("OpenRouter session_id", () => { + type OpenRouterModule = Awaited>; + type CreateOpenRouterOptions = Parameters[0]; + + async function withCapturedOpenRouterOptions( + run: (captured: CreateOpenRouterOptions[]) => Promise, + // Lets wire-level tests swap the fetch handed to the real factory while + // still capturing the options mux built (captured pre-wrap). + wrapOptions?: (options: CreateOpenRouterOptions) => CreateOpenRouterOptions + ): Promise { + const originalRegistry = PROVIDER_REGISTRY.openrouter; + const module = await originalRegistry(); + const captured: CreateOpenRouterOptions[] = []; + PROVIDER_REGISTRY.openrouter = () => + Promise.resolve({ + ...module, + createOpenRouter: (options: CreateOpenRouterOptions) => { + captured.push(options); + return module.createOpenRouter(wrapOptions ? wrapOptions(options) : options); + }, + }); + try { + await run(captured); + } finally { + PROVIDER_REGISTRY.openrouter = originalRegistry; + } + } + + it("stamps a per-workspace session_id into extraBody", async () => { + await withCapturedOpenRouterOptions(async (captured) => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ openrouter: { apiKey: "or-test" } }); + + const result = await factory.createModel("openrouter:openai/gpt-5", undefined, { + workspaceId: "ws1234abcd", + }); + expect(result.success).toBe(true); + expect(captured).toHaveLength(1); + expect(captured[0]?.extraBody).toEqual({ session_id: "mux-ws1234abcd" }); + }); + }); + }); + + it("stamps through the resolveAndCreateModel seam production callers use", async () => { + await withCapturedOpenRouterOptions(async (captured) => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ openrouter: { apiKey: "or-test" } }); + + const result = await factory.resolveAndCreateModel( + "openrouter:openai/gpt-5", + "off", + undefined, + { workspaceId: "ws1234abcd" } + ); + expect(result.success).toBe(true); + expect(captured[0]?.extraBody).toEqual({ session_id: "mux-ws1234abcd" }); + }); + }); + }); + + it("lets an explicit config session_id win over the workspace default", async () => { + await withCapturedOpenRouterOptions(async (captured) => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ + openrouter: { apiKey: "or-test", session_id: "static-session" }, + }); + + const result = await factory.createModel("openrouter:openai/gpt-5", undefined, { + workspaceId: "ws1234abcd", + }); + expect(result.success).toBe(true); + expect(captured[0]?.extraBody).toEqual({ session_id: "static-session" }); + }); + }); + }); + + it("treats config session_id: null as a full opt-out (nothing sent)", async () => { + await withCapturedOpenRouterOptions(async (captured) => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ + openrouter: { apiKey: "or-test", session_id: null }, + }); + + const result = await factory.createModel("openrouter:openai/gpt-5", undefined, { + workspaceId: "ws1234abcd", + }); + expect(result.success).toBe(true); + expect(captured).toHaveLength(1); + expect(captured[0]?.extraBody).toBeUndefined(); + }); + }); + }); + + it("does not stamp over a configured x-session-id header", async () => { + await withCapturedOpenRouterOptions(async (captured) => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ + openrouter: { apiKey: "or-test", headers: { "X-Session-Id": "my-static" } }, + }); + + const result = await factory.createModel("openrouter:openai/gpt-5", undefined, { + workspaceId: "ws1234abcd", + }); + expect(result.success).toBe(true); + expect(captured[0]?.extraBody).toBeUndefined(); + }); + }); + }); + + it("honors a session_id set via the SDK-conventional config extraBody", async () => { + await withCapturedOpenRouterOptions(async (captured) => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ + openrouter: { apiKey: "or-test", extraBody: { session_id: "team-shared" } }, + }); + + const result = await factory.createModel("openrouter:openai/gpt-5", undefined, { + workspaceId: "ws1234abcd", + }); + expect(result.success).toBe(true); + // Lifted to the body root — no literal nested "extraBody" field. + expect(captured[0]?.extraBody).toEqual({ session_id: "team-shared" }); + }); + }); + }); + + it("clamps the stamped session_id to OpenRouter's 256-char cap", async () => { + await withCapturedOpenRouterOptions(async (captured) => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ openrouter: { apiKey: "or-test" } }); + + // Legacy workspace ids embed project/branch basenames and can be long. + const legacyId = "p".repeat(300); + const result = await factory.createModel("openrouter:openai/gpt-5", undefined, { + workspaceId: legacyId, + }); + expect(result.success).toBe(true); + const sessionId = captured[0]?.extraBody?.session_id; + if (typeof sessionId !== "string") { + throw new Error("expected a stamped string session_id"); + } + expect(sessionId).toHaveLength(256); + expect(sessionId).toBe(`mux-${legacyId}`.slice(0, 256)); + }); + }); + }); + + it("keeps mux-internal config keys out of the request body", async () => { + await withCapturedOpenRouterOptions(async (captured) => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ + openrouter: { + apiKey: "or-test", + apiKeyFile: "/tmp/never-send-this", + baseUrl: "https://openrouter.ai/api/v1", + enabled: true, + displayName: "My OpenRouter", + }, + }); + + const result = await factory.createModel("openrouter:openai/gpt-5", undefined, { + workspaceId: "ws1234abcd", + }); + expect(result.success).toBe(true); + expect(captured[0]?.extraBody).toEqual({ session_id: "mux-ws1234abcd" }); + }); + }); + }); + + it("omits session_id when no workspace context is available", async () => { + await withCapturedOpenRouterOptions(async (captured) => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ openrouter: { apiKey: "or-test" } }); + + const result = await factory.createModel("openrouter:openai/gpt-5"); + expect(result.success).toBe(true); + expect(captured).toHaveLength(1); + expect(captured[0]?.extraBody).toBeUndefined(); + }); + }); + }); + + it("sends session_id at the top level of the request body", async () => { + const bodies: unknown[] = []; + // Object.assign preserves fetch statics (matching this file's mock-fetch + // pattern) so runtimes touching e.g. fetch.preconnect keep working. + const respondingFetch = Object.assign( + (_input: Parameters[0], init?: Parameters[1]) => { + const body = init?.body; + bodies.push(typeof body === "string" ? JSON.parse(body) : body); + return Promise.resolve( + new Response( + JSON.stringify({ + id: "gen-test", + object: "chat.completion", + created: 1, + model: "openai/gpt-5", + choices: [ + { + index: 0, + message: { role: "assistant", content: "ok" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { headers: { "content-type": "application/json" } } + ) + ); + }, + fetch + ) as typeof fetch; + + await withCapturedOpenRouterOptions( + async () => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ openrouter: { apiKey: "or-test" } }); + + const result = await factory.createModel("openrouter:openai/gpt-5", undefined, { + workspaceId: "ws1234abcd", + }); + expect(result.success).toBe(true); + if (!result.success) return; + + await generateText({ model: result.data, prompt: "hi" }); + expect(bodies[0]).toMatchObject({ session_id: "mux-ws1234abcd" }); + }); + }, + (options) => ({ ...options, fetch: respondingFetch }) + ); + }); + + it("composes session_id with nested provider routing options", async () => { + await withCapturedOpenRouterOptions(async (captured) => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ + openrouter: { apiKey: "or-test", sort: "price" }, + }); + + const result = await factory.createModel("openrouter:openai/gpt-5", undefined, { + workspaceId: "ws1234abcd", + }); + expect(result.success).toBe(true); + expect(captured[0]?.extraBody).toEqual({ + provider: { sort: "price" }, + session_id: "mux-ws1234abcd", + }); + }); + }); + }); + }); + it("honors explicit mux-gateway prefixes for compatibility", async () => { await withTempConfig(async (config, factory) => { config.saveProvidersConfig({ diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 1e595aed024..c5b6173b910 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -66,7 +66,11 @@ import { normalizeToCanonical, } from "@/common/utils/ai/models"; import type { AnthropicCacheTtl } from "@/common/utils/ai/cacheStrategy"; -import { MUX_APP_ATTRIBUTION_TITLE, MUX_APP_ATTRIBUTION_URL } from "@/constants/appAttribution"; +import { + MUX_APP_ATTRIBUTION_TITLE, + MUX_APP_ATTRIBUTION_URL, + MUX_OPENROUTER_SESSION_ID_PREFIX, +} from "@/constants/appAttribution"; import { resolveCustomProviderCredentials, resolveProviderCredentials, @@ -1069,6 +1073,7 @@ export class ProviderModelFactory { muxProviderOptions?: MuxProviderOptions, opts?: { agentInitiated?: boolean; + workspaceId?: string; routeContext?: RouteContext; providersConfig?: ProvidersConfig; } @@ -1640,15 +1645,26 @@ export class ProviderModelFactory { const resolvedApiKey = creds.apiKey; const baseFetch = getProviderFetch(providerConfig); - // Extract standard provider settings and Mux-local metadata before building extraBody. - // OpenRouter also has a request-level `models` fallback field capped at 3 entries; our - // configured `models` catalog can be longer and must not be forwarded as request input. + // Extract standard provider settings and Mux-local metadata before building extraBody + // so none of them leak into request bodies as junk top-level fields. baseURL must be + // stripped by its post-rename spelling — the baseUrl→baseURL rewrite above runs before + // the provider branches, so the lowercase key never matches it here — and apiKeyFile + // is a local filesystem path that must never reach the wire. OpenRouter also has a + // request-level `models` fallback field capped at 3 entries; our configured `models` + // catalog can be longer and must not be forwarded as request input. Remaining + // unrecognized keys pass through as request options (the verbatim-parity contract). const { apiKey: _apiKey, + apiKeyFile: _apiKeyFile, baseUrl, + baseURL: _baseURL, headers, fetch: _fetch, + enabled: _enabled, + providerType: _providerType, + displayName: _displayName, models: _models, + modelParameters: _modelParameters, ...extraOptions } = providerConfig; @@ -1677,6 +1693,36 @@ export class ProviderModelFactory { } } + // Config-level `extraBody` is the OpenRouter SDK's own settings key for + // extra request-body fields (verbatim-parity contract), so lift its + // entries to the body root instead of shipping a literal nested + // "extraBody" field; on a key collision the extraBody spelling wins as + // the more explicit intent. Non-object values are dropped. + const configExtraBody = otherOptions.extraBody; + delete otherOptions.extraBody; + if ( + typeof configExtraBody === "object" && + configExtraBody !== null && + !Array.isArray(configExtraBody) + ) { + Object.assign(otherOptions, configExtraBody); + } + + // Session key semantics: an explicit config session_id (top-level or + // via extraBody) wins over the per-workspace default, and null or any + // non-string disables session tagging — stripped rather than forwarded, + // since OpenRouter's contract is a string of at most 256 chars. A + // configured x-session-id header is the documented header spelling of + // the same key; the body field would win server-side, so never stamp + // over it. + const hasExplicitSessionId = "session_id" in otherOptions; + if (hasExplicitSessionId && typeof otherOptions.session_id !== "string") { + delete otherOptions.session_id; + } + const hasSessionIdHeader = Object.keys(headers ?? {}).some( + (headerName) => headerName.toLowerCase() === "x-session-id" + ); + // Build extraBody with provider nesting if routing options exist let extraBody: Record | undefined; if (Object.keys(routingOptions).length > 0) { @@ -1685,6 +1731,22 @@ export class ProviderModelFactory { extraBody = otherOptions; } + // Per-workspace session grouping: session_id is OpenRouter's explicit + // sticky-routing key (a workspace's turns keep hitting the same + // upstream provider's prompt cache even as the opening messages change + // between requests) and groups the workspace's requests in the + // Sessions view of its Logs page. A workspace is mux's conversation + // unit, so its id is the session key. + // See: https://openrouter.ai/docs/guides/best-practices/prompt-caching + if (opts?.workspaceId != null && !hasExplicitSessionId && !hasSessionIdHeader) { + extraBody = { + ...extraBody, + // Sliced to OpenRouter's 256-char cap: legacy workspace ids embed + // project/branch basenames and can exceed it. + session_id: `${MUX_OPENROUTER_SESSION_ID_PREFIX}${opts.workspaceId}`.slice(0, 256), + }; + } + // Lazy-load OpenRouter provider to reduce startup time const { createOpenRouter } = await PROVIDER_REGISTRY.openrouter(); const providerFetch = baseFetch; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 9ebd64d38fe..c33c930b01f 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -6329,7 +6329,14 @@ export class WorkspaceService extends EventEmitter { try { const candidates = await this.getWorkspaceTitleModelCandidates(workspaceId); - const result = await generateWorkspaceIdentity(trimmedMessage, candidates, this.aiService); + const result = await generateWorkspaceIdentity( + trimmedMessage, + candidates, + this.aiService, + undefined, + undefined, + workspaceId + ); if (result.success) { const persistResult = await this.updateWorkspaceTitleState(workspaceId, { title: result.data.title, @@ -6666,7 +6673,8 @@ export class WorkspaceService extends EventEmitter { candidates, this.aiService, conversationContext, - latestUserText + latestUserText, + workspaceId ); if (!result.success) { return Err("Title generation failed"); diff --git a/src/node/services/workspaceStatusGenerator.ts b/src/node/services/workspaceStatusGenerator.ts index bfd059d8932..6d61398d496 100644 --- a/src/node/services/workspaceStatusGenerator.ts +++ b/src/node/services/workspaceStatusGenerator.ts @@ -133,6 +133,12 @@ export async function generateWorkspaceStatus( candidates: readonly string[], aiService: AIService, options: BuildWorkspaceStatusPromptOptions & { + /** + * Workspace the status belongs to; forwarded to model creation so + * provider-level session grouping (e.g. OpenRouter session_id) covers + * status traffic too. + */ + workspaceId?: string; /** * Best-effort cost telemetry: status generation bypasses StreamManager, * so the caller records the successful candidate's usage into @@ -188,6 +194,7 @@ export async function generateWorkspaceStatus( // re-attributed by a concurrent Coder catalog refresh. const modelResult = await aiService.createModelWithPinnedMetadata(modelString, { agentInitiated: true, + ...(options.workspaceId != null ? { workspaceId: options.workspaceId } : {}), }); if (!modelResult.success) { lastError = mapModelCreationError(modelResult.error, modelString); diff --git a/src/node/services/workspaceTitleGenerator.ts b/src/node/services/workspaceTitleGenerator.ts index 0cea63887d3..3286b8ec0e6 100644 --- a/src/node/services/workspaceTitleGenerator.ts +++ b/src/node/services/workspaceTitleGenerator.ts @@ -185,7 +185,13 @@ export async function generateWorkspaceIdentity( /** Optional conversation turns context used for regenerate-title prompts. */ conversationContext?: string, /** Optional most recent user message; included as additional context only — not given precedence over older turns. */ - latestUserMessage?: string + latestUserMessage?: string, + /** + * Workspace the title belongs to (absent for pre-creation naming); + * forwarded to model creation so provider-level session grouping + * (e.g. OpenRouter session_id) covers title traffic too. + */ + workspaceId?: string ): Promise> { if (candidates.length === 0) { return Err({ type: "unknown", raw: "No model candidates provided for name generation" }); @@ -202,6 +208,7 @@ export async function generateWorkspaceIdentity( const modelResult = await aiService.createModel(modelString, undefined, { agentInitiated: true, + ...(workspaceId != null ? { workspaceId } : {}), }); if (!modelResult.success) { lastError = mapModelCreationError(modelResult.error, modelString); From 450033f412fc74a95cc5c87360542059ce193826 Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:44:50 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20normalize=20all=20Ope?= =?UTF-8?q?nRouter=20session=5Fids=20to=20the=20256-char=20cap=20collision?= =?UTF-8?q?-free?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit config overrides (either spelling) are clamped like the generated stamp instead of forwarded verbatim, and overlong values keep a readable head plus a sha256 digest tail so two legacy workspace ids sharing a long project prefix cannot merge into one session. Co-Authored-By: Claude Fable 5 --- .../services/providerModelFactory.test.ts | 43 ++++++++++++++++--- src/node/services/providerModelFactory.ts | 27 ++++++++++-- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index b70ff2abeb8..e31f02a7e18 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -1668,23 +1668,54 @@ describe("ProviderModelFactory routing", () => { }); }); - it("clamps the stamped session_id to OpenRouter's 256-char cap", async () => { + it("clamps overlong session_ids to the 256-char cap without merging distinct ids", async () => { await withCapturedOpenRouterOptions(async (captured) => { await withTempConfig(async (config, factory) => { config.saveProvidersConfig({ openrouter: { apiKey: "or-test" } }); - // Legacy workspace ids embed project/branch basenames and can be long. - const legacyId = "p".repeat(300); + // Legacy workspace ids are `-` basenames: two + // workspaces under one long project name differ only in the tail, + // so a plain prefix truncation would merge their sessions. + const longProject = "p".repeat(280); + for (const branch of ["alpha", "beta"]) { + const result = await factory.createModel("openrouter:openai/gpt-5", undefined, { + workspaceId: `${longProject}-${branch}`, + }); + expect(result.success).toBe(true); + } + const sessionIds = captured.map((options) => options?.extraBody?.session_id); + expect(sessionIds).toHaveLength(2); + for (const sessionId of sessionIds) { + if (typeof sessionId !== "string") { + throw new Error("expected a stamped string session_id"); + } + expect(sessionId).toHaveLength(256); + expect(sessionId.startsWith("mux-ppp")).toBe(true); + } + expect(sessionIds[0]).not.toBe(sessionIds[1]); + }); + }); + }); + + it("clamps an overlong explicit config session_id instead of forwarding it", async () => { + await withCapturedOpenRouterOptions(async (captured) => { + await withTempConfig(async (config, factory) => { + config.saveProvidersConfig({ + openrouter: { apiKey: "or-test", session_id: "s".repeat(300) }, + }); + const result = await factory.createModel("openrouter:openai/gpt-5", undefined, { - workspaceId: legacyId, + workspaceId: "ws1234abcd", }); expect(result.success).toBe(true); const sessionId = captured[0]?.extraBody?.session_id; if (typeof sessionId !== "string") { - throw new Error("expected a stamped string session_id"); + throw new Error("expected a session_id string"); } expect(sessionId).toHaveLength(256); - expect(sessionId).toBe(`mux-${legacyId}`.slice(0, 256)); + expect(sessionId.startsWith("sss")).toBe(true); + // Still the user's static override, not the workspace stamp. + expect(sessionId).not.toContain("ws1234abcd"); }); }); }); diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index c5b6173b910..60155fca234 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -1,4 +1,5 @@ import assert from "node:assert"; +import { createHash } from "node:crypto"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import type { XaiProviderOptions } from "@ai-sdk/xai"; import { fromNodeProviderChain } from "@aws-sdk/credential-providers"; @@ -520,6 +521,22 @@ function wrapFetchWithMuxGatewayAutoLogout( return Object.assign(wrappedFetch, baseFetch) as typeof fetch; } +/** + * Normalize a session key to OpenRouter's 256-char session_id cap. Plain + * truncation would merge distinct overlong keys that share a prefix (legacy + * workspace ids are `-` basenames, so workspaces under one + * long project name differ only in the tail), so overlong values keep a + * readable head plus a digest of the full value for uniqueness. + */ +function clampOpenRouterSessionId(sessionId: string): string { + const MAX_LENGTH = 256; + if (sessionId.length <= MAX_LENGTH) { + return sessionId; + } + const digest = createHash("sha256").update(sessionId).digest("hex").slice(0, 16); + return `${sessionId.slice(0, MAX_LENGTH - digest.length - 1)}-${digest}`; +} + /** * Get fetch function for provider - use custom if provided, otherwise unlimited timeout default */ @@ -1718,6 +1735,10 @@ export class ProviderModelFactory { const hasExplicitSessionId = "session_id" in otherOptions; if (hasExplicitSessionId && typeof otherOptions.session_id !== "string") { delete otherOptions.session_id; + } else if (typeof otherOptions.session_id === "string") { + // Normalize explicit overrides to the cap too: forwarding an + // overlong key verbatim would fail every request on that provider. + otherOptions.session_id = clampOpenRouterSessionId(otherOptions.session_id); } const hasSessionIdHeader = Object.keys(headers ?? {}).some( (headerName) => headerName.toLowerCase() === "x-session-id" @@ -1741,9 +1762,9 @@ export class ProviderModelFactory { if (opts?.workspaceId != null && !hasExplicitSessionId && !hasSessionIdHeader) { extraBody = { ...extraBody, - // Sliced to OpenRouter's 256-char cap: legacy workspace ids embed - // project/branch basenames and can exceed it. - session_id: `${MUX_OPENROUTER_SESSION_ID_PREFIX}${opts.workspaceId}`.slice(0, 256), + session_id: clampOpenRouterSessionId( + `${MUX_OPENROUTER_SESSION_ID_PREFIX}${opts.workspaceId}` + ), }; }