Skip to content

Commit 69cc714

Browse files
authored
fix: send x-opencode-session for OpenCode Go requests (#349)
## Requirement or Bug OpenCode Go models fail in the desktop app and the CLI with `400 Request is missing x-opencode-session and cannot be routed efficiently`. ## Bug Reproduction Steps 1. Configure an OpenCode Go provider (base URL `https://opencode.ai/zen/go/v1`) with an API key. 2. Send any message in a session. 3. The model request fails with the 400 above. ## Root Cause OpenCode Go requires a stable session id in the `x-opencode-session` header for each conversation ([docs](https://opencode.ai/docs/go/#where-can-i-use-it)). The engine only built this header in the legacy `kosong/model` requester. Requests now go through the `llm-adapter` requester, which never sent it. Even the legacy path read `conversationId`, which no turn sets; turns set only `cacheKey` (the session id). This is a fundamental fix. ## Code Changes - `llm-adapter/model/model-requester-impl.ts`: merge `x-opencode-session: <cacheKey>` into the model default headers when the base URL is an `https://` `opencode.ai` host. - `llm-adapter/model/catalog-service.ts`: the connectivity ping sends a random session id, so the settings "test connection" works against OpenCode. - Move `opencodeSession.ts` into `llm-adapter/model/opencode-session.ts`; the legacy requester imports it from there and falls back to `cacheKey`. ## Behavior Changes and Affected Users | Behavior | Before | After | Who relies on the old behavior | Escape hatch | | --- | --- | --- | --- | --- | | Requests to `https://*.opencode.ai` | No session header; Go rejects them with 400 | `x-opencode-session` is the session id (a header the user set in provider config wins); the ping probe uses a random id | Nobody (requests failed) | None needed | | Requests to every other provider | Unchanged | Unchanged (the helper returns no header for other hosts) | n/a | n/a | Tests: `test/llm-adapter/model/modelRequester.test.ts` adds two cases (header sent to OpenCode; not sent to other hosts or without a session id). The first case fails without the fix. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [ ] I have linked a related issue (external PRs: issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. - [x] The behavior-change table above is complete, and every removed behavior or flipped default is named in the changeset and either has an escape hatch or was explicitly approved by a maintainer in this PR. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed OpenCode Go requests that could fail when the required session header was missing. * Requests now include a session header when applicable, while preserving an existing session header. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent bca1910 commit 69cc714

6 files changed

Lines changed: 75 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
"@pymodel/pythinker-desktop": patch
4+
---
5+
6+
Fix OpenCode Go requests failing with "Request is missing x-opencode-session".

‎packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
import { translateProviderError } from '#/kosong/protocol/errors';
1313
import type { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol';
1414

15-
import { opencodeSessionHeaders } from './opencodeSession';
15+
import { opencodeSessionHeaders } from '#/llm-adapter/model/opencode-session';
1616

1717
import type { AuthProvider, Model } from './catalog';
1818
import type {
@@ -92,7 +92,10 @@ export class ModelRequesterImpl implements ModelRequester {
9292
const options: GenerateOptions = {
9393
signal,
9494
cacheKey: params?.cacheKey,
95-
extraHeaders: opencodeSessionHeaders(this.model.baseUrl, params?.conversationId),
95+
extraHeaders: opencodeSessionHeaders(
96+
this.model.baseUrl,
97+
params?.conversationId ?? params?.cacheKey,
98+
),
9699
sampling: params?.sampling,
97100
thinking:
98101
params?.thinkingEffort === undefined

‎packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { randomUUID } from 'node:crypto';
2+
13
import { parsePythinkerCodeCustomHeaders } from '@pymodel/pythinker-code-oauth';
24

35
import { Disposable } from '#/_base/di/lifecycle';
@@ -171,7 +173,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
171173
messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }], toolCalls: [] }],
172174
},
173175
undefined,
174-
{ maxCompletionTokens: 512 },
176+
{ cacheKey: randomUUID(), maxCompletionTokens: 512 },
175177
)) {
176178
if (event.type === 'part' && event.part.type === 'text') {
177179
text += event.part.text;

‎packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
type LlmRequestContent,
1818
type LlmRequestEvent,
1919
type LlmRequester,
20+
mergeRequestHeaders,
2021
} from '#human/llm/requester/requester';
2122
import type { TokenUsage } from '#human/llm/usage';
2223

@@ -32,6 +33,7 @@ import { fromLlmAssistantMessage, toLlmMessage, type Tool } from '../contract/me
3233
import { mergeUsagePatch } from '#human/llm/usage';
3334

3435
import type { Model } from './catalog';
36+
import { opencodeSessionHeaders } from './opencode-session';
3537
import type {
3638
ModelRequestEvent,
3739
ModelRequestInput,
@@ -159,8 +161,23 @@ export class ModelRequesterImpl implements ModelRequester {
159161
};
160162

161163
const credential = await this.model.credentialProvider?.resolve();
164+
const credentialed = applyCredential(resolved.model, credential);
165+
const sessionHeaders = opencodeSessionHeaders(
166+
credentialed.baseUrl,
167+
params?.cacheKey,
168+
credentialed.defaultHeaders,
169+
);
162170
await requester.generate(
163-
{ ...config, model: applyCredential(resolved.model, credential) },
171+
{
172+
...config,
173+
model:
174+
sessionHeaders === undefined
175+
? credentialed
176+
: {
177+
...credentialed,
178+
defaultHeaders: mergeRequestHeaders(credentialed.defaultHeaders, sessionHeaders),
179+
},
180+
},
164181
content,
165182
{
166183
signal: signal ?? new AbortController().signal,

packages/agent-core-v2/src/kosong/model/opencodeSession.ts renamed to packages/agent-core-v2/src/llm-adapter/model/opencode-session.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ export function isOpencodeGatewayBaseUrl(baseUrl: string | undefined): boolean {
1818
export function opencodeSessionHeaders(
1919
baseUrl: string | undefined,
2020
conversationId: string | undefined,
21+
existing?: Readonly<Record<string, string>>,
2122
): Record<string, string> | undefined {
2223
const id = conversationId?.trim();
2324
if (!isOpencodeGatewayBaseUrl(baseUrl) || id === undefined || id.length === 0) return undefined;
25+
if (Object.keys(existing ?? {}).some((key) => key.toLowerCase() === OPENCODE_SESSION_HEADER)) {
26+
return undefined;
27+
}
2428
return { [OPENCODE_SESSION_HEADER]: id };
2529
}

‎packages/agent-core-v2/test/llm-adapter/model/modelRequester.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,3 +467,42 @@ describe('buildStreamTiming', () => {
467467
});
468468
});
469469
});
470+
471+
describe('ModelRequesterImpl OpenCode session header', () => {
472+
async function headersFor(
473+
baseUrl: string,
474+
cacheKey: string | undefined,
475+
defaultHeaders?: Record<string, string>,
476+
) {
477+
const requester = new FakeLlmRequester();
478+
requester.handler = (_i, emit) => textStream(emit);
479+
const impl = new ModelRequesterImpl(modelWith(undefined), {
480+
resolve: () => ({
481+
requester,
482+
protocol: 'openai',
483+
model: { ...BASE_LLM_MODEL, baseUrl, defaultHeaders },
484+
}),
485+
});
486+
await collect(impl.request(INPUT, undefined, { cacheKey }));
487+
return requester.calls[0]!.config.model.defaultHeaders;
488+
}
489+
490+
it('sends the session id as x-opencode-session to the OpenCode gateway', async () => {
491+
expect(await headersFor('https://opencode.ai/zen/go/v1', 'session-1')).toEqual({
492+
'x-opencode-session': 'session-1',
493+
});
494+
});
495+
496+
it('does not send the header to other providers or without a session id', async () => {
497+
expect(await headersFor('https://api.example.test/v1', 'session-1')).toBeUndefined();
498+
expect(await headersFor('https://opencode.ai/zen/go/v1', undefined)).toBeUndefined();
499+
});
500+
501+
it('keeps a session header the user configured', async () => {
502+
expect(
503+
await headersFor('https://opencode.ai/zen/go/v1', 'session-1', {
504+
'X-OpenCode-Session': 'custom',
505+
}),
506+
).toEqual({ 'X-OpenCode-Session': 'custom' });
507+
});
508+
});

0 commit comments

Comments
 (0)