From 0d6c8dd158de3cd3129ff40a9ab176eedb4482fa Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:40:46 +0800 Subject: [PATCH] Add OpenRouter app attribution headers Attribute direct OpenRouter API requests to ChatGPTBox so usage can appear in OpenRouter app rankings and analytics. Keep attribution headers off third-party proxy endpoints by checking the resolved request origin. Reference: https://openrouter.ai/docs/app-attribution --- src/services/apis/openai-api.mjs | 17 ++ .../apis/openrouter-attribution.test.mjs | 159 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 tests/unit/services/apis/openrouter-attribution.test.mjs diff --git a/src/services/apis/openai-api.mjs b/src/services/apis/openai-api.mjs index 2a3f04b4..86ae062d 100644 --- a/src/services/apis/openai-api.mjs +++ b/src/services/apis/openai-api.mjs @@ -37,6 +37,13 @@ const OPENAI_COMPATIBLE_RUNTIME_CONFIG_KEYS = [ 'temperature', ] +const OPENROUTER_API_ORIGIN = 'https://openrouter.ai' +const OPENROUTER_ATTRIBUTION_HEADERS = { + 'HTTP-Referer': 'https://github.com/ChatGPTBox-dev/chatGPTBox', + 'X-OpenRouter-Title': 'ChatGPTBox', + 'X-OpenRouter-Categories': 'general-chat,writing-assistant', +} + function hasOpenAICompatibleRuntimeConfig(config) { if (!config || typeof config !== 'object') return false return OPENAI_COMPATIBLE_RUNTIME_CONFIG_KEYS.every((key) => Object.hasOwn(config, key)) @@ -99,6 +106,15 @@ function resolveProviderRequestShapingId(request) { return request?.providerId } +function getOpenRouterAttributionHeaders(requestUrl) { + try { + if (new URL(requestUrl).origin !== OPENROUTER_API_ORIGIN) return {} + } catch { + return {} + } + return OPENROUTER_ATTRIBUTION_HEADERS +} + function resolveOllamaKeepAliveBaseUrl(request) { const requestUrl = normalizeBaseUrl(request?.requestUrl) if (requestUrl) { @@ -313,6 +329,7 @@ export async function generateAnswersWithOpenAICompatibleApi(port, question, ses apiKey: request.apiKey, config: runtimeConfig, provider: providerRequestShapingId, + extraHeaders: getOpenRouterAttributionHeaders(request.requestUrl), allowLegacyResponseField: request.provider.allowLegacyResponseField, }) diff --git a/tests/unit/services/apis/openrouter-attribution.test.mjs b/tests/unit/services/apis/openrouter-attribution.test.mjs new file mode 100644 index 00000000..119f9234 --- /dev/null +++ b/tests/unit/services/apis/openrouter-attribution.test.mjs @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + generateAnswersWithOpenAICompatibleApi, +} from '../../../../src/services/apis/openai-api.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +const ATTRIBUTION_HEADER_NAMES = [ + 'HTTP-Referer', + 'X-OpenRouter-Title', + 'X-OpenRouter-Categories', +] + +function createConfig(overrides = {}) { + return { + maxConversationContextLength: 3, + maxResponseTokenLength: 256, + temperatureOverrideEnabled: false, + temperature: 1, + ...overrides, + } +} + +async function captureRequest(t, config, session) { + let capturedInput + let capturedInit + t.mock.method(globalThis, 'fetch', async (input, init) => { + capturedInput = input + capturedInit = init + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"OK"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + await generateAnswersWithOpenAICompatibleApi( + createFakePort(), + 'CurrentQ', + session, + config, + ) + + return { capturedInput, capturedInit } +} + +function assertOpenRouterAttribution(headers) { + assert.equal(headers['HTTP-Referer'], 'https://github.com/ChatGPTBox-dev/chatGPTBox') + assert.equal(headers['X-OpenRouter-Title'], 'ChatGPTBox') + assert.equal(headers['X-OpenRouter-Categories'], 'general-chat,writing-assistant') +} + +function assertNoOpenRouterAttribution(headers) { + for (const headerName of ATTRIBUTION_HEADER_NAMES) { + assert.equal(Object.hasOwn(headers, headerName), false) + } +} + +test('adds app attribution headers for built-in OpenRouter requests', async (t) => { + const config = createConfig({ + providerSecrets: { + openrouter: 'sk-or-test', + }, + }) + const session = { + modelName: 'openRouter_auto', + conversationRecords: [], + isRetry: false, + } + + const { capturedInput, capturedInit } = await captureRequest(t, config, session) + + assert.equal(capturedInput, 'https://openrouter.ai/api/v1/chat/completions') + assert.equal(capturedInit.headers.Authorization, 'Bearer sk-or-test') + assertOpenRouterAttribution(capturedInit.headers) + + const body = JSON.parse(capturedInit.body) + for (const headerName of ATTRIBUTION_HEADER_NAMES) { + assert.equal(Object.hasOwn(body, headerName), false) + } +}) + +test('adds app attribution headers for custom providers that call OpenRouter directly', async (t) => { + const config = createConfig({ + customOpenAIProviders: [ + { + id: 'direct-openrouter', + name: 'Direct OpenRouter', + baseUrl: 'https://openrouter.ai/api/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + enabled: true, + }, + ], + providerSecrets: { + 'direct-openrouter': 'direct-key', + }, + }) + const session = { + modelName: 'customModel', + conversationRecords: [], + isRetry: false, + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'direct-openrouter', + customName: 'openai/gpt-5.2', + customUrl: '', + apiKey: '', + active: true, + }, + } + + const { capturedInput, capturedInit } = await captureRequest(t, config, session) + + assert.equal(capturedInput, 'https://openrouter.ai/api/v1/chat/completions') + assert.equal(capturedInit.headers.Authorization, 'Bearer direct-key') + assertOpenRouterAttribution(capturedInit.headers) +}) + +test('omits OpenRouter attribution headers for third-party proxy endpoints', async (t) => { + const config = createConfig({ + customOpenAIProviders: [ + { + id: 'openrouter-proxy', + name: 'OpenRouter Proxy', + baseUrl: 'https://proxy.example.com/v1', + chatCompletionsPath: '/chat/completions', + completionsPath: '/completions', + sourceProviderId: 'openrouter', + enabled: true, + }, + ], + providerSecrets: { + 'openrouter-proxy': 'proxy-key', + }, + }) + const session = { + modelName: 'customModel', + conversationRecords: [], + isRetry: false, + apiMode: { + groupName: 'customApiModelKeys', + itemName: 'customModel', + isCustom: true, + providerId: 'openrouter-proxy', + customName: 'openai/gpt-5.2', + customUrl: '', + apiKey: '', + active: true, + }, + } + + const { capturedInput, capturedInit } = await captureRequest(t, config, session) + + assert.equal(capturedInput, 'https://proxy.example.com/v1/chat/completions') + assert.equal(capturedInit.headers.Authorization, 'Bearer proxy-key') + assertNoOpenRouterAttribution(capturedInit.headers) +})