Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
47 changes: 47 additions & 0 deletions docs/merge-gateway-setup.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/integrations/compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const EXPECTED_PRESETS = [
'together',
'groq',
'hicap',
'merge-gateway',
'azure-openai',
'openrouter',
'lmstudio',
Expand Down
48 changes: 48 additions & 0 deletions src/integrations/discoveryService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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<string | null>(requestUrl).toBe(
'https://api-gateway.merge.dev/v1/models',
)
expect<string | null>(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'
Expand Down
2 changes: 2 additions & 0 deletions src/integrations/discoveryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
139 changes: 139 additions & 0 deletions src/integrations/gateways/merge-gateway.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => Promise<unknown>
}
}
}

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')
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<string | null>(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()
})
})
Loading