diff --git a/.changeset/opencode-go-session-header.md b/.changeset/opencode-go-session-header.md new file mode 100644 index 000000000..587f63e42 --- /dev/null +++ b/.changeset/opencode-go-session-header.md @@ -0,0 +1,6 @@ +--- +"@pymodel/pythinker-code": patch +"@pymodel/pythinker-desktop": patch +--- + +Fix OpenCode Go requests failing with "Request is missing x-opencode-session". diff --git a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts index 26bfcc147..330670ce9 100644 --- a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts +++ b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts @@ -12,7 +12,7 @@ import type { import { translateProviderError } from '#/kosong/protocol/errors'; import type { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; -import { opencodeSessionHeaders } from './opencodeSession'; +import { opencodeSessionHeaders } from '#/llm-adapter/model/opencode-session'; import type { AuthProvider, Model } from './catalog'; import type { @@ -92,7 +92,10 @@ export class ModelRequesterImpl implements ModelRequester { const options: GenerateOptions = { signal, cacheKey: params?.cacheKey, - extraHeaders: opencodeSessionHeaders(this.model.baseUrl, params?.conversationId), + extraHeaders: opencodeSessionHeaders( + this.model.baseUrl, + params?.conversationId ?? params?.cacheKey, + ), sampling: params?.sampling, thinking: params?.thinkingEffort === undefined diff --git a/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts b/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts index f071aebdb..362833faf 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + import { parsePythinkerCodeCustomHeaders } from '@pymodel/pythinker-code-oauth'; import { Disposable } from '#/_base/di/lifecycle'; @@ -171,7 +173,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }], toolCalls: [] }], }, undefined, - { maxCompletionTokens: 512 }, + { cacheKey: randomUUID(), maxCompletionTokens: 512 }, )) { if (event.type === 'part' && event.part.type === 'text') { text += event.part.text; diff --git a/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts b/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts index 19c1110b0..0398bdfd4 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts @@ -17,6 +17,7 @@ import { type LlmRequestContent, type LlmRequestEvent, type LlmRequester, + mergeRequestHeaders, } from '#human/llm/requester/requester'; import type { TokenUsage } from '#human/llm/usage'; @@ -32,6 +33,7 @@ import { fromLlmAssistantMessage, toLlmMessage, type Tool } from '../contract/me import { mergeUsagePatch } from '#human/llm/usage'; import type { Model } from './catalog'; +import { opencodeSessionHeaders } from './opencode-session'; import type { ModelRequestEvent, ModelRequestInput, @@ -159,8 +161,23 @@ export class ModelRequesterImpl implements ModelRequester { }; const credential = await this.model.credentialProvider?.resolve(); + const credentialed = applyCredential(resolved.model, credential); + const sessionHeaders = opencodeSessionHeaders( + credentialed.baseUrl, + params?.cacheKey, + credentialed.defaultHeaders, + ); await requester.generate( - { ...config, model: applyCredential(resolved.model, credential) }, + { + ...config, + model: + sessionHeaders === undefined + ? credentialed + : { + ...credentialed, + defaultHeaders: mergeRequestHeaders(credentialed.defaultHeaders, sessionHeaders), + }, + }, content, { signal: signal ?? new AbortController().signal, diff --git a/packages/agent-core-v2/src/kosong/model/opencodeSession.ts b/packages/agent-core-v2/src/llm-adapter/model/opencode-session.ts similarity index 83% rename from packages/agent-core-v2/src/kosong/model/opencodeSession.ts rename to packages/agent-core-v2/src/llm-adapter/model/opencode-session.ts index 1cd2d7bad..a04a5127a 100644 --- a/packages/agent-core-v2/src/kosong/model/opencodeSession.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/opencode-session.ts @@ -18,8 +18,12 @@ export function isOpencodeGatewayBaseUrl(baseUrl: string | undefined): boolean { export function opencodeSessionHeaders( baseUrl: string | undefined, conversationId: string | undefined, + existing?: Readonly>, ): Record | undefined { const id = conversationId?.trim(); if (!isOpencodeGatewayBaseUrl(baseUrl) || id === undefined || id.length === 0) return undefined; + if (Object.keys(existing ?? {}).some((key) => key.toLowerCase() === OPENCODE_SESSION_HEADER)) { + return undefined; + } return { [OPENCODE_SESSION_HEADER]: id }; } diff --git a/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts b/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts index c6af97c74..98136c7b0 100644 --- a/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts +++ b/packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts @@ -467,3 +467,42 @@ describe('buildStreamTiming', () => { }); }); }); + +describe('ModelRequesterImpl OpenCode session header', () => { + async function headersFor( + baseUrl: string, + cacheKey: string | undefined, + defaultHeaders?: Record, + ) { + const requester = new FakeLlmRequester(); + requester.handler = (_i, emit) => textStream(emit); + const impl = new ModelRequesterImpl(modelWith(undefined), { + resolve: () => ({ + requester, + protocol: 'openai', + model: { ...BASE_LLM_MODEL, baseUrl, defaultHeaders }, + }), + }); + await collect(impl.request(INPUT, undefined, { cacheKey })); + return requester.calls[0]!.config.model.defaultHeaders; + } + + it('sends the session id as x-opencode-session to the OpenCode gateway', async () => { + expect(await headersFor('https://opencode.ai/zen/go/v1', 'session-1')).toEqual({ + 'x-opencode-session': 'session-1', + }); + }); + + it('does not send the header to other providers or without a session id', async () => { + expect(await headersFor('https://api.example.test/v1', 'session-1')).toBeUndefined(); + expect(await headersFor('https://opencode.ai/zen/go/v1', undefined)).toBeUndefined(); + }); + + it('keeps a session header the user configured', async () => { + expect( + await headersFor('https://opencode.ai/zen/go/v1', 'session-1', { + 'X-OpenCode-Session': 'custom', + }), + ).toEqual({ 'X-OpenCode-Session': 'custom' }); + }); +});