diff --git a/README.md b/README.md index e8a224f607..6800cfb1dd 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,7 @@ Advanced and source-build guides: | OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers | | Z.AI GLM Coding Plan | `/provider` or OpenAI-compatible env vars | Uses `OPENAI_API_KEY` at `https://api.z.ai/api/coding/paas/v4` and defaults to `glm-5.2` | | AI/ML API | `/provider` or `AIMLAPI_API_KEY` ([setup guide](docs/aimlapi-setup.md)) | Uses `https://api.aimlapi.com/v1`, auto-detects the OpenAI-compatible route from `AIMLAPI_API_KEY`, sends OpenClaude attribution headers, and discovers chat-capable models from the public `/models` catalog | +| Merge Gateway | `/provider` or `MERGE_GATEWAY_API_KEY` ([setup guide](docs/merge-gateway-setup.md)) | Multi-provider model router at `https://api-gateway.merge.dev/v1/openai`; supports Chat Completions, Responses, authenticated model discovery, and routing policies | | Concentrate | `/provider` or `CONCENTRATE_API_KEY` | Unified OpenAI-compatible gateway at `https://api.concentrate.ai/v1`; defaults to `deepseek-v4-flash` and auto-discovers the chat model catalog | | LLMTR | `/provider` or OpenAI-compatible env vars | Multi-model gateway at `https://llmtr.com/v1`; `/provider` and `--provider llmtr` default to `deepseek/deepseek-v4-flash`, while raw env setup must set `OPENAI_BASE_URL=https://llmtr.com/v1` and `OPENAI_MODEL`; accepts `LLMTR_API_KEY` or `OPENAI_API_KEY` after the route is selected and discovers tool-capable Chat Completions models from the public catalog | | ApiSmart | `/provider` or `APISMART_API_KEY` | Uses `https://gw.apismart.ai/v1`, defaults to `DEEPSEEK_V4_FLASH`, and supports optional `APISMART_MODEL` plus authenticated model discovery | diff --git a/docs/merge-gateway-setup.md b/docs/merge-gateway-setup.md new file mode 100644 index 0000000000..a7670a0c0f --- /dev/null +++ b/docs/merge-gateway-setup.md @@ -0,0 +1,47 @@ +# Merge Gateway setup + +OpenClaude connects to [Merge Gateway](https://gateway.merge.dev) through its OpenAI-compatible endpoint at `https://api-gateway.merge.dev/v1/openai`. The route supports Merge's multi-provider model catalog and routing policies with a dedicated `MERGE_GATEWAY_API_KEY`. + +## Guided setup + +1. Create an API key in the [Merge Gateway dashboard](https://gateway.merge.dev/api-keys). +2. Start OpenClaude and run `/provider`. +3. Choose **Add provider**, then **Merge Gateway**. +4. Paste the API key when prompted. +5. Choose a discovered model, or select **Default routing policy** to let the policy attached to the key choose the provider and model. +6. Choose **Chat Completions** or **Responses** for the API format supported by the selected model and route. + +The model picker keeps a cached catalog for one day and can be refreshed manually. If discovery is temporarily unavailable, the routing-policy and GPT-5.5 fallback entries remain selectable. + +## CLI setup + +```bash +export MERGE_GATEWAY_API_KEY="your-api-key" +openclaude --provider merge-gateway --model openai/gpt-5.5 +``` + +To delegate model selection to a Merge Gateway routing policy: + +```bash +openclaude --provider merge-gateway --model default_routing +``` + +The dedicated Merge credential is not replaced by `OPENAI_API_KEY`. + +## Verify + +Inside OpenClaude: + +1. Run `/status` and confirm the active provider is **Merge Gateway** and the base URL is `https://api-gateway.merge.dev/v1/openai`. +2. Run `/model`, refresh the catalog, and confirm Merge Gateway models appear. +3. Send a short prompt and confirm the request appears in the [Gateway dashboard](https://gateway.merge.dev). + +For direct catalog troubleshooting, use the authenticated public model endpoint: + +```bash +curl --fail --silent --show-error \ + -H "Authorization: Bearer $MERGE_GATEWAY_API_KEY" \ + "https://api-gateway.merge.dev/v1/models?limit=5" +``` + +Never commit the API key or paste it into issue or pull-request text. diff --git a/src/integrations/compatibility.test.ts b/src/integrations/compatibility.test.ts index 934b7503f9..7e70f0d048 100644 --- a/src/integrations/compatibility.test.ts +++ b/src/integrations/compatibility.test.ts @@ -31,6 +31,7 @@ const EXPECTED_PRESETS = [ 'together', 'groq', 'hicap', + 'merge-gateway', 'azure-openai', 'openrouter', 'lmstudio', diff --git a/src/integrations/discoveryService.test.ts b/src/integrations/discoveryService.test.ts index 53277195cd..d23ca98da7 100644 --- a/src/integrations/discoveryService.test.ts +++ b/src/integrations/discoveryService.test.ts @@ -22,6 +22,7 @@ const originalEnv = { OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, OPENAI_MODEL: process.env.OPENAI_MODEL, APISMART_API_KEY: process.env.APISMART_API_KEY, + MERGE_GATEWAY_API_KEY: process.env.MERGE_GATEWAY_API_KEY, ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS, CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI, @@ -64,6 +65,7 @@ function clearProviderEnv(): void { delete process.env.OPENAI_API_KEYS delete process.env.OPENAI_MODEL delete process.env.APISMART_API_KEY + delete process.env.MERGE_GATEWAY_API_KEY delete process.env.ANTHROPIC_CUSTOM_HEADERS delete process.env.CLAUDE_CODE_USE_OPENAI delete process.env.CLAUDE_CODE_USE_GEMINI @@ -102,6 +104,7 @@ afterEach(() => { restoreEnvValue('OPENAI_API_KEYS') restoreEnvValue('OPENAI_MODEL') restoreEnvValue('APISMART_API_KEY') + restoreEnvValue('MERGE_GATEWAY_API_KEY') restoreEnvValue('ANTHROPIC_CUSTOM_HEADERS') restoreEnvValue('CLAUDE_CODE_USE_OPENAI') restoreEnvValue('CLAUDE_CODE_USE_GEMINI') @@ -119,6 +122,51 @@ afterEach(() => { }) describe('discoverModelsForRoute', () => { + test('uses Merge Gateway native discovery with its dedicated credential', async () => { + const { discoverModelsForRoute } = await loadDiscoveryServiceModule() + process.env.MERGE_GATEWAY_API_KEY = 'merge-key' + process.env.OPENAI_API_KEY = 'unrelated-openai-key' + + let requestUrl: string | null = null + let authorization: string | null = null + setMockFetch(mock((input: string | URL | Request, init?: RequestInit) => { + requestUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + authorization = new Headers(init?.headers).get('authorization') + return Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + model: 'anthropic/claude-sonnet-5', + display_name: 'Claude Sonnet 5', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + }) as unknown as typeof globalThis.fetch) + + const result = await discoverModelsForRoute('merge-gateway', { + forceRefresh: true, + }) + + expect(requestUrl).toBe( + 'https://api-gateway.merge.dev/v1/models', + ) + expect(authorization).toBe('Bearer merge-key') + expect(result).toMatchObject({ + routeId: 'merge-gateway', + source: 'network', + discoveredModelCount: 1, + }) + }) + test('does not send an ApiSmart key to an overridden discovery URL', async () => { const { discoverModelsForRoute } = await loadDiscoveryServiceModule() process.env.APISMART_API_KEY = 'apismart-secret' diff --git a/src/integrations/discoveryService.ts b/src/integrations/discoveryService.ts index 84f90bf5ec..78dd896d5f 100644 --- a/src/integrations/discoveryService.ts +++ b/src/integrations/discoveryService.ts @@ -360,6 +360,7 @@ async function runDiscovery( baseUrl: getRouteBaseUrl(routeId, options), apiKey: getRouteDiscoveryApiKey(routeId, options), headers: getRouteDiscoveryHeaders(routeId, options), + path: discovery.path, }) if (rawModels === null) { return null @@ -378,6 +379,7 @@ async function runDiscovery( baseUrl: getRouteBaseUrl(routeId, options), apiKey: getRouteDiscoveryApiKey(routeId, options), headers: getRouteDiscoveryHeaders(routeId, options), + path: discovery.path, }) return models?.map(model => toDiscoveredModelEntry(model)) ?? null } diff --git a/src/integrations/gateways/merge-gateway.test.ts b/src/integrations/gateways/merge-gateway.test.ts new file mode 100644 index 0000000000..34452249c8 --- /dev/null +++ b/src/integrations/gateways/merge-gateway.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, mock, test } from 'bun:test' + +import '../index.js' +import { + getRouteCredentialEnvVars, + resolveActiveRouteIdFromEnv, +} from '../routeMetadata.js' +import { applyProviderFlag } from '../../utils/providerFlag.js' +import { createOpenAIShimClient } from '../../services/api/openaiShim.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { asMockFetch } from '../../test/typedMocks.js' +import mergeGateway from './merge-gateway.js' + +const mapModel = mergeGateway.catalog?.discovery?.mapModel +type OpenAIShimClient = { + beta: { + messages: { + create: (params: Record) => Promise + } + } +} + +describe('Merge Gateway', () => { + test('uses a dedicated OpenAI-compatible gateway route', () => { + expect(mergeGateway.id).toBe('merge-gateway') + expect(mergeGateway.defaultBaseUrl).toBe( + 'https://api-gateway.merge.dev/v1/openai', + ) + expect(mergeGateway.setup.credentialEnvVars).toEqual([ + 'MERGE_GATEWAY_API_KEY', + ]) + expect(mergeGateway.setup.dedicatedCredentialsOnly).toBe(true) + expect(mergeGateway.transportConfig.kind).toBe('openai-compatible') + expect( + mergeGateway.transportConfig.openaiShim?.supportsApiFormatSelection, + ).toBe(true) + }) + + test('keeps routing policy and concrete model fallbacks in the catalog', () => { + expect(mergeGateway.catalog?.source).toBe('hybrid') + expect(mergeGateway.catalog?.discovery?.requiresAuth).toBe(true) + expect(mergeGateway.catalog?.models?.map(model => model.apiName)).toEqual([ + 'default_routing', + 'openai/gpt-5.5', + ]) + }) + + test('--provider selects the route and uses only its dedicated credential', async () => { + await acquireSharedMutationLock('merge-gateway.test.ts') + expect(getRouteCredentialEnvVars('merge-gateway')).toEqual([ + 'MERGE_GATEWAY_API_KEY', + ]) + + const previousEnv = { ...process.env } + const originalFetch = globalThis.fetch + try { + process.env.MERGE_GATEWAY_API_KEY = 'merge-key' + process.env.OPENAI_API_KEY = 'unrelated-openai-key' + delete process.env.OPENAI_BASE_URL + + expect( + applyProviderFlag('merge-gateway', [ + '--provider', + 'merge-gateway', + '--model', + 'default_routing', + ]), + ).toEqual({}) + expect(String(process.env.OPENAI_BASE_URL)).toBe( + 'https://api-gateway.merge.dev/v1/openai', + ) + expect(String(process.env.OPENAI_MODEL)).toBe('default_routing') + expect(resolveActiveRouteIdFromEnv(process.env)).toBe('merge-gateway') + + let authorization: string | null = null + globalThis.fetch = asMockFetch(mock((_input, init) => { + authorization = new Headers(init?.headers).get('authorization') + return Promise.resolve( + new Response( + JSON.stringify({ + id: 'chatcmpl-merge', + model: 'default_routing', + choices: [ + { + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ) + })) + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'default_routing', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 32, + stream: false, + }) + + expect(authorization).toBe('Bearer merge-key') + } finally { + mock.restore() + globalThis.fetch = originalFetch + for (const name of Object.keys(process.env)) { + if (!(name in previousEnv)) delete process.env[name] + } + Object.assign(process.env, previousEnv) + releaseSharedMutationLock() + } + }) + + test('maps native and OpenAI-compatible model list shapes', () => { + if (!mapModel) throw new Error('mapModel missing') + + expect( + mapModel({ + model: 'anthropic/claude-sonnet-5', + display_name: 'Claude Sonnet 5', + }), + ).toEqual({ + id: 'merge-gateway-anthropic/claude-sonnet-5', + apiName: 'anthropic/claude-sonnet-5', + label: 'Claude Sonnet 5 (via Merge Gateway)', + }) + expect(mapModel({ id: 'openai/gpt-5.5' })).toEqual({ + id: 'merge-gateway-openai/gpt-5.5', + apiName: 'openai/gpt-5.5', + label: 'openai/gpt-5.5 (via Merge Gateway)', + }) + expect(mapModel({})).toBeNull() + expect(mapModel(null)).toBeNull() + }) +}) diff --git a/src/integrations/gateways/merge-gateway.ts b/src/integrations/gateways/merge-gateway.ts new file mode 100644 index 0000000000..e8b9c63ca8 --- /dev/null +++ b/src/integrations/gateways/merge-gateway.ts @@ -0,0 +1,104 @@ +import { defineGateway } from '../define.js' + +/** Convert Merge's native catalog object into an OpenClaude route entry. */ +function mapMergeGatewayModel(raw: unknown) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return null + } + + const model = raw as Record + const apiName = + typeof model.id === 'string' + ? model.id.trim() + : typeof model.model === 'string' + ? model.model.trim() + : '' + if (!apiName) { + return null + } + + const label = + typeof model.display_name === 'string' && model.display_name.trim() + ? model.display_name.trim() + : apiName + + return { + id: `merge-gateway-${apiName}`, + apiName, + label: `${label} (via Merge Gateway)`, + } +} + +export default defineGateway({ + id: 'merge-gateway', + label: 'Merge Gateway', + category: 'aggregating', + defaultBaseUrl: 'https://api-gateway.merge.dev/v1/openai', + defaultModel: 'openai/gpt-5.5', + supportsModelRouting: true, + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['MERGE_GATEWAY_API_KEY'], + dedicatedCredentialsOnly: true, + }, + startup: { + probeReadiness: 'openai-compatible-models', + }, + transportConfig: { + kind: 'openai-compatible', + openaiShim: { + supportsApiFormatSelection: true, + supportsAuthHeaders: false, + ui: { + showAuthHeader: false, + showAuthHeaderValue: false, + showCustomHeaders: false, + }, + }, + }, + preset: { + id: 'merge-gateway', + description: 'Merge Gateway multi-provider model router', + apiKeyEnvVars: ['MERGE_GATEWAY_API_KEY'], + modelEnvVars: ['OPENAI_MODEL'], + vendorId: 'openai', + }, + validation: { + kind: 'credential-env', + routing: { + matchDefaultBaseUrl: true, + matchBaseUrlHosts: ['api-gateway.merge.dev'], + }, + credentialEnvVars: ['MERGE_GATEWAY_API_KEY'], + missingCredentialMessage: + 'Merge Gateway auth is required. Set MERGE_GATEWAY_API_KEY.', + }, + catalog: { + source: 'hybrid', + discovery: { + kind: 'openai-compatible', + requiresAuth: true, + path: '/v1/models', + mapModel: mapMergeGatewayModel, + }, + discoveryCacheTtl: '1d', + discoveryRefreshMode: 'background-if-stale', + allowManualRefresh: true, + models: [ + { + id: 'merge-gateway-default-routing', + apiName: 'default_routing', + label: 'Default routing policy', + notes: 'Uses the routing policy configured for the Merge Gateway API key.', + }, + { + id: 'merge-gateway-gpt-5.5', + apiName: 'openai/gpt-5.5', + label: 'GPT-5.5 (via Merge Gateway)', + modelDescriptorId: 'gpt-5.5', + }, + ], + }, + usage: { supported: false }, +}) diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 261857aeba..1affa8ce5e 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -39,6 +39,7 @@ import gatewayHicap from '../gateways/hicap.js' import gatewayKimiCode from '../gateways/kimi-code.js' import gatewayLlmtr from '../gateways/llmtr.js' import gatewayLmstudio from '../gateways/lmstudio.js' +import gatewayMergeGateway from '../gateways/merge-gateway.js' import gatewayMistral from '../gateways/mistral.js' import gatewayNvidiaNim from '../gateways/nvidia-nim.js' import gatewayOllama from '../gateways/ollama.js' @@ -92,7 +93,7 @@ import modelXai from '../models/xai.js' import modelXiaomiMimo from '../models/xiaomi-mimo.js' export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorFireworks, vendorGemini, vendorLongcat, vendorMinimax, vendorMoonshot, vendorNearai, vendorOpenai, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[] -export const GATEWAY_DESCRIPTORS = [gatewayAimlapi, gatewayApismart, gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCloudflare, gatewayConcentrate, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLlmtr, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex, gatewayXiaomiMimoToken] as const satisfies readonly GatewayDescriptor[] +export const GATEWAY_DESCRIPTORS = [gatewayAimlapi, gatewayApismart, gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCloudflare, gatewayConcentrate, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLlmtr, gatewayLmstudio, gatewayMergeGateway, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex, gatewayXiaomiMimoToken] as const satisfies readonly GatewayDescriptor[] export const ANTHROPIC_PROXY_DESCRIPTORS = [anthropicproxyCustom] as const satisfies readonly AnthropicProxyDescriptor[] export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandFireworks, brandGemini, brandGlm, brandGpt, brandKimi, brandLing, brandLlama, brandLongcat, brandMacaron, brandMinimax, brandMistral, brandNearai, brandNemotron, brandOpenaiCompatibleAlias, brandQwen, brandTencent, brandXai, brandXiaomiMimo] as const satisfies readonly BrandDescriptor[] export const MODEL_DESCRIPTOR_GROUPS = [modelClaude, modelDeepseek, modelFireworksMerged, modelGemini, modelGlm, modelGpt, modelKimi, modelLing, modelLlama, modelLongcat, modelMacaron, modelMinimax, modelMistral, modelNearai, modelNemotron, modelOpenaiCompatibleAlias, modelOpencode, modelQwen, modelTencent, modelXai, modelXiaomiMimo] as const satisfies readonly (readonly ModelDescriptor[])[] diff --git a/src/integrations/generated/integrationManifest.generated.ts b/src/integrations/generated/integrationManifest.generated.ts index 82fb29592b..c924bc27d2 100644 --- a/src/integrations/generated/integrationManifest.generated.ts +++ b/src/integrations/generated/integrationManifest.generated.ts @@ -315,6 +315,20 @@ export const PROVIDER_PRESET_MANIFEST = [ "OPENAI_MODEL" ] }, + { + "preset": "merge-gateway", + "routeKind": "gateway", + "routeId": "merge-gateway", + "vendorId": "openai", + "gatewayId": "merge-gateway", + "description": "Merge Gateway multi-provider model router", + "apiKeyEnvVars": [ + "MERGE_GATEWAY_API_KEY" + ], + "modelEnvVars": [ + "OPENAI_MODEL" + ] + }, { "preset": "minimax", "routeKind": "vendor", @@ -592,6 +606,7 @@ export const ORDERED_PROVIDER_PRESETS = [ "atomic-chat", "ollama", "longcat", + "merge-gateway", "minimax", "mistral", "moonshotai", diff --git a/src/integrations/index.test.ts b/src/integrations/index.test.ts index 0248f27a96..be562035f7 100644 --- a/src/integrations/index.test.ts +++ b/src/integrations/index.test.ts @@ -67,6 +67,7 @@ describe('loaded registry validation', () => { // Virtual model — the gateway's smart router resolves it server-side, // so there is no concrete model descriptor to reference. 'gitlawb-opengateway:opengateway-auto', + 'merge-gateway:merge-gateway-default-routing', // Cloudflare Workers AI serves provider-specific quantized builds // (`@cf/...`) with no shared cross-provider model descriptor, the same // situation as azure-deployment above. See gateways/cloudflare.ts (#1100). diff --git a/src/utils/providerDiscovery.ts b/src/utils/providerDiscovery.ts index 367f173f44..1bcdd1789f 100644 --- a/src/utils/providerDiscovery.ts +++ b/src/utils/providerDiscovery.ts @@ -158,6 +158,27 @@ export function getOpenAICompatibleModelsBaseUrl(baseUrl?: string): string { ).replace(/\/+$/, '') } +export function getOpenAICompatibleModelsUrl( + baseUrl?: string, + discoveryPath?: string, +): string { + const resolvedBaseUrl = getOpenAICompatibleModelsBaseUrl(baseUrl) + if (!discoveryPath?.trim()) { + return `${resolvedBaseUrl}/models` + } + + const parsedBaseUrl = new URL(resolvedBaseUrl) + const resolvedUrl = new URL( + discoveryPath.trim(), + `${resolvedBaseUrl}/`, + ) + if (resolvedUrl.origin !== parsedBaseUrl.origin) { + throw new Error('Model discovery path must stay on the provider origin') + } + + return resolvedUrl.toString() +} + export function getLocalOpenAICompatibleProviderLabel(baseUrl?: string): string { try { const parsed = new URL(getOpenAICompatibleModelsBaseUrl(baseUrl)) @@ -263,11 +284,15 @@ export async function listOpenAICompatibleModels(options?: { baseUrl?: string apiKey?: string headers?: Record + path?: string }): Promise { const { signal, clear } = withTimeoutSignal(5000) try { - const baseUrl = getOpenAICompatibleModelsBaseUrl(options?.baseUrl) - const isBankr = baseUrl.toLowerCase().includes('bankr') + const modelsUrl = getOpenAICompatibleModelsUrl( + options?.baseUrl, + options?.path, + ) + const isBankr = modelsUrl.toLowerCase().includes('bankr') const headers = { ...(options?.headers ?? {}), ...(options?.apiKey @@ -277,7 +302,7 @@ export async function listOpenAICompatibleModels(options?: { : {}), } const response = await fetch( - `${baseUrl}/models`, + modelsUrl, { method: 'GET', headers: Object.keys(headers).length > 0 ? headers : undefined, @@ -310,11 +335,15 @@ export async function fetchOpenAICompatibleModelsRaw(options?: { baseUrl?: string apiKey?: string headers?: Record + path?: string }): Promise[] | null> { const { signal, clear } = withTimeoutSignal(5000) try { - const baseUrl = getOpenAICompatibleModelsBaseUrl(options?.baseUrl) - const isBankr = baseUrl.toLowerCase().includes('bankr') + const modelsUrl = getOpenAICompatibleModelsUrl( + options?.baseUrl, + options?.path, + ) + const isBankr = modelsUrl.toLowerCase().includes('bankr') const headers = { ...(options?.headers ?? {}), ...(options?.apiKey @@ -323,7 +352,7 @@ export async function fetchOpenAICompatibleModelsRaw(options?: { : { Authorization: `Bearer ${options.apiKey}` } : {}), } - const response = await fetch(`${baseUrl}/models`, { + const response = await fetch(modelsUrl, { method: 'GET', headers: Object.keys(headers).length > 0 ? headers : undefined, signal, diff --git a/web/src/data/providers.ts b/web/src/data/providers.ts index 70c373763d..05e8cd7770 100644 --- a/web/src/data/providers.ts +++ b/web/src/data/providers.ts @@ -1,5 +1,5 @@ // Mirrors the provider preset manifest in -// src/integrations/generated/integrationManifest.generated.ts (v0.26.0) +// src/integrations/generated/integrationManifest.generated.ts (v0.29.1) // plus the OAuth/special entries injected by the /provider picker. export type ProviderGroup = @@ -148,6 +148,14 @@ export const providers: Provider[] = [ envVars: ['LLMTR_API_KEY', 'OPENAI_API_KEY'], notes: '/provider and --provider llmtr default to deepseek/deepseek-v4-flash; raw env setup must set OPENAI_BASE_URL=https://llmtr.com/v1 and OPENAI_MODEL. Uses public discovery of tool-capable chat models.', }, + { + id: 'merge-gateway', + name: 'Merge Gateway', + group: 'gateways', + setup: '/provider or env vars', + envVars: ['MERGE_GATEWAY_API_KEY'], + notes: 'Multi-provider model router with Chat Completions, Responses, authenticated catalog discovery, and routing policies.', + }, { id: 'near-ai', name: 'NEAR AI',