From 1fc05ca5900e18a82a600dee5d2ee7f639c6f552 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:30:24 -0500 Subject: [PATCH] fix(core): share affinity in provider session headers (#51931) --- .../core/src/plugin/provider/github-copilot.ts | 3 ++- packages/core/src/plugin/provider/openai.ts | 9 +++++++-- packages/core/src/session/affinity.ts | 8 ++++++++ packages/core/src/session/model-request.ts | 3 ++- .../test/plugin/provider-github-copilot.test.ts | 10 ++++++---- .../core/test/plugin/provider-openai.test.ts | 16 +++++++++++++--- 6 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/session/affinity.ts diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 8a2e9690077..38291859055 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -11,6 +11,7 @@ import { Model } from "../../model.js" import { Agent } from "../../agent.js" import { define } from "@opencode/plugin/effect/plugin" import { Provider } from "../../provider.js" +import { SessionAffinity } from "../../session/affinity.js" import type { PluginInternal } from "../internal.js" const clientID = "Ov23li8tweQw6odWQebz" @@ -272,7 +273,7 @@ export const GithubCopilotPlugin = define({ .pipe(Effect.orElseSucceed(() => undefined)) const interaction = interactionType(evt.kind, session?.parentID !== undefined) evt.headers["X-Interaction-Type"] = interaction - evt.headers["X-Interaction-Id"] = evt.sessionID + evt.headers["X-Interaction-Id"] = session ? SessionAffinity.get(session) : evt.sessionID if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent" }), { providerID: Provider.ID.githubCopilot }, diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 5c7bdc38aff..092758cbd1d 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -9,6 +9,7 @@ import { Bus } from "../../bus.js" import { Integration } from "../../integration.js" import { OauthCallbackPage } from "../../oauth/page.js" import { Provider } from "../../provider.js" +import { SessionAffinity } from "../../session/affinity.js" import type { PluginInternal } from "../internal.js" const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" @@ -299,12 +300,16 @@ export const OpenAIPlugin = define({ yield* ctx.session.hook( "model.request", (evt) => - Effect.sync(() => { + Effect.gen(function* () { if (!chatgpt) return if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com") evt.baseURL = codexBaseURL + const session = yield* ctx.session + .get({ sessionID: evt.sessionID }) + .pipe(Effect.orElseSucceed(() => undefined)) evt.headers.originator = "opencode" - evt.headers["session-id"] = evt.sessionID + // ChatGPT routes its prompt cache on this header, so children share the parent's. + evt.headers["session-id"] = session ? SessionAffinity.get(session) : evt.sessionID }), { providerID: Provider.ID.openai }, ) diff --git a/packages/core/src/session/affinity.ts b/packages/core/src/session/affinity.ts new file mode 100644 index 00000000000..5c76a911452 --- /dev/null +++ b/packages/core/src/session/affinity.ts @@ -0,0 +1,8 @@ +export * as SessionAffinity from "./affinity.js" + +import type { SessionSchema } from "./schema.js" + +// TODO: Should the `model.request` hook expose affinity so plugins stop deriving it from `ctx.session.get`? +/** The Session ID that groups model requests for provider cache routing: children share the parent's, forks the fork source's. */ +export const get = (session: Pick) => + session.parentID ?? session.fork?.sessionID ?? session.id diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index a282d5c4a3c..90071ceecb6 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -31,6 +31,7 @@ import { Permission } from "../permission.js" import { PluginHooks } from "../plugin/hooks.js" import { QuestionTool } from "../tool/plugin/question.js" import { Tool } from "../tool.js" +import { SessionAffinity } from "./affinity.js" import { SessionModelTransport } from "./model-transport.js" import { SessionProviderContext } from "./provider-context.js" import { SessionRunnerModel } from "./runner/model.js" @@ -270,7 +271,7 @@ export const layer = Layer.effect( const entries = Object.entries(shaped.options) const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k))) - const affinity = session.parentID ?? session.fork?.sessionID ?? session.id + const affinity = SessionAffinity.get(session) const base = LLM.request({ model: model.model, http: { diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 69dc2f008b6..fcae3b3cfdf 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -183,10 +183,11 @@ describe("GithubCopilotPlugin", () => { it.effect("classifies child-session steps as subagent interactions", () => Effect.gen(function* () { yield* addPlugin() - const event = yield* modelRequest((yield* sessions()).child, "primary") + const ids = yield* sessions() + const event = yield* modelRequest(ids.child, "primary") expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-subagent", - "X-Interaction-Id": event.sessionID, + "X-Interaction-Id": ids.parent, "x-initiator": "agent", }) }), @@ -207,10 +208,11 @@ describe("GithubCopilotPlugin", () => { it.effect("classifies compaction requests by kind rather than agent", () => Effect.gen(function* () { yield* addPlugin() - const event = yield* modelRequest((yield* sessions()).child, "compaction", "build") + const ids = yield* sessions() + const event = yield* modelRequest(ids.child, "compaction", "build") expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-compaction", - "X-Interaction-Id": event.sessionID, + "X-Interaction-Id": ids.parent, "x-initiator": "agent", }) }), diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 7ca86ee7d0e..1374d6b4e8e 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,6 +1,6 @@ import { Money } from "@opencode/schema/money" import { Agent } from "@opencode/schema/agent" -import { Session } from "@opencode/schema/session" +import { Session } from "@opencode/core/session" import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses" import { describe, expect } from "bun:test" import { ConfigProvider, DateTime, Effect } from "effect" @@ -41,10 +41,14 @@ function required(value: T | undefined): T { return value } -const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) { +const request = Effect.fn(function* ( + providerID: Provider.ID, + baseURL: string, + sessionID = Session.ID.make("ses_test"), +) { const hooks = yield* PluginHooks.Service const event = yield* hooks.trigger("session", "model.request", { - sessionID: Session.ID.make("ses_test"), + sessionID, agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), kind: "primary", @@ -156,6 +160,12 @@ describe("OpenAIPlugin", () => { expect(custom.headers).not.toHaveProperty("originator") expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us") expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) + const sessions = yield* Session.Service + const location = yield* Location.Service + const parent = yield* sessions.create({ location: { directory: location.directory } }) + const child = yield* sessions.create({ parentID: parent.id }) + const childRequest = yield* request(Provider.ID.openai, "https://api.openai.com/v1", child.id) + expect(childRequest.headers).toMatchObject({ "session-id": parent.id }) const eligible = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) expect(eligible.package).toBe("@opencode/ai/providers/openai") expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })