Skip to content

Commit df1a354

Browse files
authored
fix: send x-opencode-session header for OpenCode Go gateways (#389)
OpenCode Go now requires a stable x-opencode-session per conversation (https://opencode.ai/docs/go/#where-can-i-use-it). Add a shared helper that applies the header (and a self-identifying User-Agent) whenever the channel gateway points at opencode, using tsb-{ChatId} as the stable session id for chat paths and tsb-global for models/embeddings.
1 parent d69b29b commit df1a354

4 files changed

Lines changed: 93 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System;
2+
using System.Net.Http;
3+
using TelegramSearchBot.Service.AI.LLM;
4+
using Xunit;
5+
6+
namespace TelegramSearchBot.LLM.Test.Service.AI.LLM {
7+
public class OpencodeSessionHeadersTests {
8+
private static HttpClient Client() => new HttpClient();
9+
10+
[Theory]
11+
[InlineData("https://opencode.ai/zen/v1")]
12+
[InlineData("https://opencode.ai/zen/v1/")]
13+
[InlineData("https://www.opencode.ai/zen/v1")]
14+
public void Apply_Opengateways_AddsSessionAndUserAgent(string gateway) {
15+
using var client = Client();
16+
17+
OpencodeSessionHeaders.Apply(client, gateway, "tsb-123");
18+
19+
Assert.Equal("tsb-123", string.Join(",", client.DefaultRequestHeaders.GetValues("x-opencode-session")));
20+
Assert.Equal("TelegramSearchBot/1.0", string.Join(",", client.DefaultRequestHeaders.GetValues("User-Agent")));
21+
}
22+
23+
[Theory]
24+
[InlineData("https://api.openai.com/v1")]
25+
[InlineData("https://api.minimaxi.com/v1")]
26+
[InlineData(null)]
27+
[InlineData("")]
28+
public void Apply_NonOpencodeGateways_AddsNothing(string gateway) {
29+
using var client = Client();
30+
31+
OpencodeSessionHeaders.Apply(client, gateway, "tsb-123");
32+
33+
Assert.False(client.DefaultRequestHeaders.Contains("x-opencode-session"));
34+
}
35+
36+
[Fact]
37+
public void Apply_Twice_DoesNotDuplicateSessionHeader() {
38+
using var client = Client();
39+
40+
OpencodeSessionHeaders.Apply(client, "https://opencode.ai/zen/v1", "tsb-1");
41+
OpencodeSessionHeaders.Apply(client, "https://opencode.ai/zen/v1", "tsb-1");
42+
43+
Assert.Single(client.DefaultRequestHeaders.GetValues("x-opencode-session"));
44+
}
45+
}
46+
}

TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ private async IAsyncEnumerable<string> ExecWithResponsesApiAsync(
266266

267267
// --- Create Responses client ---
268268
using var httpClient = _httpClientFactory.CreateClient();
269+
OpencodeSessionHeaders.Apply(httpClient, channel, binding, $"tsb-{ChatId}");
269270
var clientOptions = new OpenAIClientOptions {
270271
Endpoint = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)),
271272
Transport = new HttpClientPipelineTransport(httpClient),
@@ -543,6 +544,7 @@ public async IAsyncEnumerable<string> ResumeFromSnapshotAsync(
543544
}
544545

545546
using var httpClient = _httpClientFactory.CreateClient();
547+
OpencodeSessionHeaders.Apply(httpClient, channel, binding, $"tsb-{snapshot.ChatId}");
546548
var clientOptions = new OpenAIClientOptions {
547549
Endpoint = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)),
548550
Transport = new HttpClientPipelineTransport(httpClient),
@@ -761,6 +763,7 @@ public async Task<float[]> GenerateEmbeddingsAsync(string text, string modelName
761763
}
762764

763765
using var httpClient = _httpClientFactory.CreateClient();
766+
OpencodeSessionHeaders.Apply(httpClient, endpoint);
764767
var clientOptions = new OpenAIClientOptions {
765768
Endpoint = new Uri(endpoint),
766769
Transport = new HttpClientPipelineTransport(httpClient),
@@ -835,6 +838,7 @@ public async Task<IEnumerable<string>> GetAllModels(LLMChannel channel) {
835838
UseProxy = true
836839
};
837840
using var httpClient = new HttpClient(handler);
841+
OpencodeSessionHeaders.Apply(httpClient, channel.Gateway);
838842

839843
var clientOptions = new OpenAIClientOptions {
840844
Endpoint = new Uri(channel.Gateway),
@@ -865,6 +869,7 @@ public async Task<IEnumerable<string>> GetAllModels(LLMChannel channel, LLMApiBi
865869
UseProxy = true
866870
};
867871
using var httpClient = new HttpClient(handler);
872+
OpencodeSessionHeaders.Apply(httpClient, channel, binding);
868873

869874
var clientOptions = new OpenAIClientOptions {
870875
Endpoint = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)),
@@ -888,6 +893,7 @@ public async Task<bool> IsHealthyAsync(LLMChannel channel, LLMApiBinding binding
888893

889894
public async Task<IEnumerable<ModelWithCapabilities>> GetAllModelsWithCapabilities(LLMChannel channel) {
890895
using var httpClient = _httpClientFactory.CreateClient();
896+
OpencodeSessionHeaders.Apply(httpClient, channel.Gateway);
891897

892898
try {
893899
var internalApiUrl = channel.Gateway.TrimEnd('/') + "/dashboard/onboarding/models";
@@ -939,6 +945,7 @@ public async Task<string> AnalyzeImageAsync(string photoPath, string modelName,
939945
}
940946

941947
using var httpClient = _httpClientFactory.CreateClient();
948+
OpencodeSessionHeaders.Apply(httpClient, channel, binding);
942949

943950
// For image analysis, use Chat Completions API (vision support is more mature)
944951
var clientOptions = new OpenAIClientOptions {

TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ public virtual async Task<IEnumerable<string>> GetAllModels(LLMChannel channel)
280280
};
281281

282282
using var httpClient = new HttpClient(handler);
283+
OpencodeSessionHeaders.Apply(httpClient, channel.Gateway);
283284

284285
// --- Client Setup ---
285286
var clientOptions = new OpenAIClientOptions {
@@ -352,6 +353,7 @@ private async Task<IEnumerable<string>> GetGenericOpenAICompatibleModels(LLMChan
352353
if (!string.IsNullOrEmpty(apiKey)) {
353354
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
354355
}
356+
OpencodeSessionHeaders.Apply(httpClient, channel, binding);
355357

356358
// 构建模型列表 URL,确保路径正确
357359
var gatewayBase = NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding)).TrimEnd('/');
@@ -483,6 +485,7 @@ public virtual async Task<IEnumerable<ModelWithCapabilities>> GetAllModelsWithCa
483485
// 尝试使用OpenAI内部API获取模型能力信息
484486
var internalApiUrl = channel.Gateway.TrimEnd('/') + "/dashboard/onboarding/models";
485487
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {channel.ApiKey}");
488+
OpencodeSessionHeaders.Apply(httpClient, channel.Gateway);
486489

487490
var response = await httpClient.GetAsync(internalApiUrl);
488491
if (response.IsSuccessStatusCode) {
@@ -1221,6 +1224,7 @@ private async IAsyncEnumerable<string> ExecWithNativeToolCallingAsync(
12211224
excludeDynamicTail: true);
12221225

12231226
using var client = _httpClientFactory.CreateClient();
1227+
OpencodeSessionHeaders.Apply(client, channel, binding, $"tsb-{ChatId}");
12241228
var clientOptions = new OpenAIClientOptions {
12251229
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding))),
12261230
Transport = new HttpClientPipelineTransport(client),
@@ -1516,6 +1520,7 @@ private async IAsyncEnumerable<string> ExecWithXmlToolCallingAsync(
15161520
excludeDynamicTail: true);
15171521

15181522
using var client = _httpClientFactory.CreateClient();
1523+
OpencodeSessionHeaders.Apply(client, channel, binding, $"tsb-{ChatId}");
15191524
var clientOptions = new OpenAIClientOptions {
15201525
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding))),
15211526
Transport = new HttpClientPipelineTransport(client),
@@ -1696,6 +1701,7 @@ public async IAsyncEnumerable<string> ResumeFromSnapshotAsync(LlmContinuationSna
16961701
excludeDynamicTail: false);
16971702

16981703
using var client = _httpClientFactory.CreateClient();
1704+
OpencodeSessionHeaders.Apply(client, channel, binding, $"tsb-{snapshot.ChatId}");
16991705
var clientOptions = new OpenAIClientOptions {
17001706
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding))),
17011707
Transport = new HttpClientPipelineTransport(client),
@@ -2005,6 +2011,7 @@ public async Task<float[]> GenerateEmbeddingsAsync(string text, string modelName
20052011
}
20062012

20072013
using var httpClient = _httpClientFactory.CreateClient();
2014+
OpencodeSessionHeaders.Apply(httpClient, channel, binding);
20082015

20092016
var clientOptions = new OpenAIClientOptions {
20102017
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, endpoint)),
@@ -2118,6 +2125,7 @@ public async Task<string> AnalyzeImageAsync(string photoPath, string modelName,
21182125
}
21192126

21202127
using var httpClient = _httpClientFactory.CreateClient();
2128+
OpencodeSessionHeaders.Apply(httpClient, channel, binding);
21212129

21222130
var clientOptions = new OpenAIClientOptions {
21232131
Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding))),
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System;
2+
using System.Net.Http;
3+
using TelegramSearchBot.Model.AI;
4+
using TelegramSearchBot.Model.Data;
5+
6+
namespace TelegramSearchBot.Service.AI.LLM {
7+
/// <summary>
8+
/// OpenCode Go requires a stable `x-opencode-session` per conversation for every request.
9+
/// https://opencode.ai/docs/go/#where-can-i-use-it
10+
/// </summary>
11+
internal static class OpencodeSessionHeaders {
12+
internal const string GlobalSessionId = "tsb-global";
13+
14+
// ponytail: session id is derived from ChatId (stable per conversation); switch to real
15+
// session keys only if opencode ever requires stricter per-thread routing.
16+
internal static void Apply(HttpClient httpClient, string gateway, string sessionId = GlobalSessionId) {
17+
if (httpClient == null ||
18+
string.IsNullOrWhiteSpace(gateway) ||
19+
!gateway.Contains("opencode", StringComparison.OrdinalIgnoreCase)) {
20+
return;
21+
}
22+
23+
httpClient.DefaultRequestHeaders.Remove("x-opencode-session");
24+
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("x-opencode-session", sessionId);
25+
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "TelegramSearchBot/1.0");
26+
}
27+
28+
// 检测用实际请求 endpoint(binding 优先,回退 channel.Gateway)。
29+
internal static void Apply(HttpClient httpClient, LLMChannel channel, LLMApiBinding binding, string sessionId = GlobalSessionId)
30+
=> Apply(httpClient, LlmBindingSupport.ResolveEndpoint(channel, binding), sessionId);
31+
}
32+
}

0 commit comments

Comments
 (0)