mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
feat(providers): live model lists for OpenRouter and OpenGateway (#2084)
* feat(providers): fetch live model lists for OpenRouter and OpenGateway Enable hybrid discovery so OpenGateway and OpenRouter load public GET /v1/models catalogs (with coding filters on OpenRouter), matching cairn-code and the Zero live-list fix. Refs #2083 * Address live model discovery review feedback. Remove hardcoded model allowlisting, deduplicate live MiMo routes, avoid duplicate startup probes, share mapping helpers, and strengthen provider documentation and tests.\n\nRefs #2083 * test(providers): Isolate OpenGateway picker discovery state. Prevent persisted live discovery cache entries from making the static catalog assertion nondeterministic. Refs #2083 * fix(test): restore OPENGATEWAY_API_KEY after discovery test The no-auth OpenGateway discovery test deletes OPENGATEWAY_API_KEY but originalEnv never snapshotted it and afterEach never restored it, so a worker starting with the credential set would run every later test in that worker without it. Snapshot and restore it like the other provider env vars. Refs #2084 * fix(integrations): preserve route shim maxTokensField for live-only discovered models * fix(test): drop unrelated permissions.test.ts optional-chaining tweak Not part of the OpenGateway/OpenRouter live discovery change; jatmn's review on #2084 flagged it as unrelated drift that should be dropped or split into its own PR. Refs #2084 * docs(integrations): Add JSDoc comments to model mapping helpers Add detailed JSDoc documentation for gateway model normalization, tooling and reasoning support detection, and core model mapping type guards and helpers across OpenGateway, OpenRouter, and modelMapping. Refs #2083 * fix(integrations): address review feedback on live discovery and proxy credentials Preserve caller credentials and custom headers for private route overrides, remove deep-research exclusion for text models, isolate test config directories, and align model picker assertions with upstream curated models. Refs #2083 Refs #2084 * test(integrations): test explicit openaiShim precedence and removeBodyFields merge Add unit test assertions verifying that explicit descriptor and catalog openaiShim configurations take precedence over inferred model settings and that removeBodyFields arrays merge correctly across layers. Refs #2083 Refs #2084 * Filter expired catalog entries at discovery boundary and revert permissions test hunk. Wrap static and merged route catalog model lists in filterAvailableCatalogEntries across all discoverModelsForRoute and refreshStartupDiscoveryForRoute return paths, preventing expired time-boxed catalog entries and live duplicates from resurfacing in model picker refresh, summary, or bootstrap additional options. Also restore permissions.test.ts to upstream/main without optional-chaining. Refs #2084 * Fix ModelCatalogEntry type import in model test suite. Import ModelCatalogEntry from descriptors.js rather than index.js to satisfy typecheck. Refs #2084 --------- Co-authored-by: euxaristia <euxaristia@users.noreply.github.com>
This commit is contained in:
@@ -451,6 +451,13 @@ Use `discoveryRefreshMode` to match the operational shape of the route:
|
||||
- `startup`
|
||||
fast local routes where startup probing is cheap and useful.
|
||||
|
||||
If an authenticated inference route exposes a public model endpoint, set
|
||||
`catalog.discovery.requiresAuth` to `false` while keeping `setup.requiresAuth`
|
||||
enabled. OpenRouter and Gitlawb Opengateway use this split: model listing is
|
||||
keyless, but inference still requires an API key. Avoid combining
|
||||
`discoveryRefreshMode: 'startup'` with an `openai-compatible-models` readiness
|
||||
probe when both execute the same request, because that doubles startup traffic.
|
||||
|
||||
## `max_tokens` vs `max_completion_tokens`
|
||||
|
||||
OpenAI-compatible APIs do not all accept the same max-token field.
|
||||
|
||||
@@ -141,6 +141,14 @@ availability can vary. Retain the selected catalog ID in client-side routing so
|
||||
its route-specific limits and capabilities are not lost when the outbound API
|
||||
model is normalized.
|
||||
|
||||
### Public aggregator model discovery
|
||||
|
||||
OpenRouter and Gitlawb Opengateway use public model-list endpoints to keep their
|
||||
hybrid catalogs current. Listing models does not require credentials, but chat
|
||||
and other inference requests still require the provider's API key. OpenRouter
|
||||
refreshes stale discovery data in the background. Opengateway refreshes once at
|
||||
startup and uses that request instead of a separate readiness probe.
|
||||
|
||||
## Descriptor Authoring Pattern
|
||||
|
||||
Normal descriptor files should:
|
||||
|
||||
@@ -16,6 +16,12 @@ import { encodeSwitchProfileValue } from '../../utils/model/modelOptions.js'
|
||||
import type { ModelOption } from '../../utils/model/modelOptions.js'
|
||||
import type { ModelSetting } from '../../utils/model/model.js'
|
||||
import type { SettingsJson } from '../../utils/settings/types.js'
|
||||
import type { ModelCatalogEntry } from '../../integrations/descriptors.js'
|
||||
import {
|
||||
filterAvailableCatalogEntries,
|
||||
getRouteDescriptor,
|
||||
} from '../../integrations/index.js'
|
||||
import { mergeRouteCatalogEntries } from '../../utils/model/routeCatalogOptions.js'
|
||||
import * as actualFastModeForModelTest from '../../utils/fastMode.js'
|
||||
import * as actualExtraUsageForModelTest from '../../utils/extraUsage.js'
|
||||
|
||||
@@ -297,13 +303,21 @@ function mockDescriptorDiscovery(options: {
|
||||
headers?: Record<string, string>
|
||||
},
|
||||
) => `${routeId}|${requestOptions?.baseUrl ?? ''}|${requestOptions?.apiKey ?? ''}|${JSON.stringify(requestOptions?.headers ?? {})}`,
|
||||
discoverModelsForRoute: mock(async () => ({
|
||||
routeId: options.routeId ?? 'openrouter',
|
||||
models: options.discoveredModels ?? options.cachedModels,
|
||||
stale: false,
|
||||
error: null,
|
||||
source: 'network',
|
||||
})),
|
||||
discoverModelsForRoute: mock(async () => {
|
||||
const routeId = options.routeId ?? 'openrouter'
|
||||
const rawStatic = getRouteDescriptor(routeId)?.catalog?.models ?? []
|
||||
const discovered = (options.discoveredModels ?? options.cachedModels) as ModelCatalogEntry[]
|
||||
const merged = filterAvailableCatalogEntries(
|
||||
mergeRouteCatalogEntries(rawStatic, discovered),
|
||||
)
|
||||
return {
|
||||
routeId,
|
||||
models: merged,
|
||||
stale: false,
|
||||
error: null,
|
||||
source: 'network',
|
||||
}
|
||||
}),
|
||||
probeRouteReadiness: mock(async () => null),
|
||||
}))
|
||||
}
|
||||
@@ -1440,6 +1454,10 @@ test('/model applies auto provider surface for single-model static descriptor pr
|
||||
mockProviderProfiles({
|
||||
getActiveProviderProfile: () => activeProfile,
|
||||
})
|
||||
mockDescriptorDiscovery({
|
||||
cachedModels: [],
|
||||
routeId: 'gitlawb-opengateway',
|
||||
})
|
||||
|
||||
// Pin the clock inside the Ling Tiny availability window (its catalog
|
||||
// entry expires via availableUntil on 2026-08-13T10:00Z) so this expected
|
||||
@@ -1532,6 +1550,140 @@ test('/model drops expired availableUntil entries from the static picker after t
|
||||
}
|
||||
})
|
||||
|
||||
test('/model merges non-empty OpenGateway discovery cache with curated entries without duplicate MiMo rows', async () => {
|
||||
const activeProfile = {
|
||||
id: 'opengateway-profile',
|
||||
name: 'Gitlawb Opengateway',
|
||||
provider: 'gitlawb-opengateway',
|
||||
baseUrl: 'https://opengateway.gitlawb.com/v1',
|
||||
model: 'mimo-v2.5-pro',
|
||||
apiKey: 'sk-opengateway',
|
||||
}
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = activeProfile.baseUrl
|
||||
process.env.OPENAI_API_KEY = activeProfile.apiKey
|
||||
process.env.OPENAI_MODEL = activeProfile.model
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1'
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID = activeProfile.id
|
||||
delete process.env.OPENROUTER_API_KEY
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.CLAUDE_CODE_USE_MISTRAL
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete process.env.OPENAI_API_BASE
|
||||
|
||||
mockProviderProfiles({
|
||||
getActiveProviderProfile: () => activeProfile,
|
||||
})
|
||||
mockDescriptorDiscovery({
|
||||
cachedModels: [
|
||||
{ id: 'mimo-v2.5-pro', apiName: 'mimo-v2.5-pro', label: 'MiMo V2.5 Pro' },
|
||||
{ id: 'xiaomi/mimo-v2.5-pro', apiName: 'mimo-v2.5-pro', label: 'MiMo V2.5 Pro Raw' },
|
||||
{ id: 'moonshotai/kimi-k3', apiName: 'moonshotai/kimi-k3', label: 'Kimi K3 (via Opengateway)' },
|
||||
],
|
||||
routeId: 'gitlawb-opengateway',
|
||||
})
|
||||
|
||||
const rendered = await renderModelCommandWithCapturedPicker(
|
||||
'descriptor-picker-auto-provider-hybrid-mode',
|
||||
)
|
||||
try {
|
||||
const optionValues = (
|
||||
rendered.getCapturedProps().optionsOverride as ModelOption[]
|
||||
).map(option => option.value)
|
||||
expect(optionValues).toEqual([
|
||||
'auto',
|
||||
'mimo-v2.5-pro',
|
||||
'mimo-v2.5',
|
||||
'mimo-v2-flash',
|
||||
'google/gemini-3.1-flash-lite',
|
||||
'minimax/minimax-m3',
|
||||
'qwen/qwen3.7-max',
|
||||
'z-ai/glm-5.2',
|
||||
'nvidia/nemotron-3-ultra-550b-a55b:free',
|
||||
'nvidia/nemotron-3-ultra-550b-a55b',
|
||||
'inclusionai/ling-3.0-flash',
|
||||
'mindai/macaron-v1-tall',
|
||||
'mindai/macaron-v1-venti',
|
||||
'tencent/hy3',
|
||||
'moonshotai/kimi-k3',
|
||||
])
|
||||
// Verify mimo-v2.5-pro appears exactly once (curated entry wins, duplicate is discarded)
|
||||
expect(optionValues.filter(val => val === 'mimo-v2.5-pro')).toHaveLength(1)
|
||||
} finally {
|
||||
rendered.instance.unmount()
|
||||
rendered.stdout.end()
|
||||
}
|
||||
})
|
||||
|
||||
test('/model OpenGateway interactive refresh preserves availability filter and hides expired models', async () => {
|
||||
const activeProfile = {
|
||||
id: 'opengateway-profile',
|
||||
name: 'Gitlawb Opengateway',
|
||||
provider: 'gitlawb-opengateway',
|
||||
baseUrl: 'https://opengateway.gitlawb.com/v1',
|
||||
model: 'auto',
|
||||
apiKey: '',
|
||||
}
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1'
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID = activeProfile.id
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = activeProfile.baseUrl
|
||||
process.env.OPENAI_MODEL = 'auto'
|
||||
delete process.env.OPENAI_API_KEY
|
||||
delete process.env.OPENGATEWAY_API_KEY
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.CLAUDE_CODE_USE_MISTRAL
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete process.env.OPENAI_API_BASE
|
||||
|
||||
mockProviderProfiles({
|
||||
getActiveProviderProfile: () => activeProfile,
|
||||
})
|
||||
mockDescriptorDiscovery({
|
||||
cachedModels: [
|
||||
{ id: 'mimo-v2.5-pro', apiName: 'mimo-v2.5-pro', label: 'MiMo V2.5 Pro' },
|
||||
{ id: 'inclusionai/ling-3.0-tiny:free', apiName: 'inclusionai/ling-3.0-tiny:free', label: 'Ling 3.0 Tiny Live' },
|
||||
{ id: 'moonshotai/kimi-k3', apiName: 'moonshotai/kimi-k3', label: 'Kimi K3' },
|
||||
],
|
||||
routeId: 'gitlawb-opengateway',
|
||||
})
|
||||
|
||||
const rendered = await renderModelCommandWithCapturedPicker(
|
||||
'opengateway-picker-refresh-availability',
|
||||
)
|
||||
try {
|
||||
const initialValues = (
|
||||
rendered.getCapturedProps().optionsOverride as ModelOption[]
|
||||
).map(option => option.value)
|
||||
expect(initialValues).not.toContain('inclusionai/ling-3.0-tiny:free')
|
||||
expect(initialValues).toContain('moonshotai/kimi-k3')
|
||||
|
||||
rendered.getCapturedProps().onRefresh?.()
|
||||
await waitForCondition(() => {
|
||||
const message = rendered.getCapturedProps().discoveryState?.message
|
||||
return (
|
||||
message !== undefined &&
|
||||
message !== 'Refreshing Gitlawb Opengateway models…'
|
||||
)
|
||||
})
|
||||
|
||||
const refreshedValues = (
|
||||
rendered.getCapturedProps().optionsOverride as ModelOption[]
|
||||
).map(option => option.value)
|
||||
expect(refreshedValues).not.toContain('inclusionai/ling-3.0-tiny:free')
|
||||
expect(refreshedValues).toContain('moonshotai/kimi-k3')
|
||||
} finally {
|
||||
rendered.instance.unmount()
|
||||
rendered.stdout.end()
|
||||
}
|
||||
})
|
||||
|
||||
test('/model applies providerProfileModelPickerMode provider override on descriptor picker load', async () => {
|
||||
useSettings({
|
||||
providerProfileModelPickerMode: 'provider',
|
||||
@@ -2684,6 +2836,91 @@ test('/model refresh reports discovered model changes for dynamic active profile
|
||||
expect(messages).toContain('Updated LM Studio models.')
|
||||
})
|
||||
|
||||
test('/model refresh on OpenGateway does not restore or mention expired models', async () => {
|
||||
const activeProfile = {
|
||||
id: 'opengateway-profile',
|
||||
name: 'Gitlawb Opengateway',
|
||||
provider: 'gitlawb-opengateway',
|
||||
baseUrl: 'https://opengateway.gitlawb.com/v1',
|
||||
model: 'auto',
|
||||
apiKey: '',
|
||||
}
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = activeProfile.baseUrl
|
||||
delete process.env.OPENAI_API_KEY
|
||||
delete process.env.OPENGATEWAY_API_KEY
|
||||
process.env.OPENAI_MODEL = activeProfile.model
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED = '1'
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID = activeProfile.id
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.CLAUDE_CODE_USE_MISTRAL
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete process.env.OPENAI_API_BASE
|
||||
|
||||
mock.module('../../integrations/discoveryCache.js', () => ({
|
||||
clearDiscoveryCache: mock(async () => {}),
|
||||
getCachedModels: mock(async () => ({
|
||||
models: [{ id: 'mimo-v2.5-pro', apiName: 'mimo-v2.5-pro' }],
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
})),
|
||||
isCacheStale: mock(async () => false),
|
||||
parseDurationString: (value: number | string) =>
|
||||
typeof value === 'number' ? value : 86_400_000,
|
||||
}))
|
||||
|
||||
mock.module('../../integrations/discoveryService.js', () => ({
|
||||
getDiscoveryCacheKey: (
|
||||
routeId: string,
|
||||
options?: { apiKey?: string; baseUrl?: string; headers?: Record<string, string> },
|
||||
) => `${routeId}|${options?.baseUrl ?? ''}|${options?.apiKey ?? ''}|${JSON.stringify(options?.headers ?? {})}`,
|
||||
discoverModelsForRoute: mock(async () => {
|
||||
const rawStatic = getRouteDescriptor('gitlawb-opengateway')?.catalog?.models ?? []
|
||||
const discovered = [
|
||||
{ id: 'mimo-v2.5-pro', apiName: 'mimo-v2.5-pro' },
|
||||
{ id: 'moonshotai/kimi-k3', apiName: 'moonshotai/kimi-k3' },
|
||||
{ id: 'inclusionai/ling-3.0-tiny:free', apiName: 'inclusionai/ling-3.0-tiny:free' },
|
||||
] as ModelCatalogEntry[]
|
||||
const merged = filterAvailableCatalogEntries(
|
||||
mergeRouteCatalogEntries(rawStatic, discovered),
|
||||
)
|
||||
return {
|
||||
routeId: 'gitlawb-opengateway',
|
||||
models: merged,
|
||||
stale: false,
|
||||
error: null,
|
||||
source: 'network',
|
||||
}
|
||||
}),
|
||||
probeRouteReadiness: mock(async () => null),
|
||||
}))
|
||||
|
||||
mockProviderProfiles({
|
||||
getActiveOpenAIModelOptionsCache: () => [],
|
||||
getActiveProviderProfile: () => activeProfile,
|
||||
})
|
||||
|
||||
const messages: string[] = []
|
||||
const { call } = await importFreshModelModule(
|
||||
'opengateway-refresh-summary-no-expired',
|
||||
)
|
||||
await call(
|
||||
(message?: string) => {
|
||||
if (message) {
|
||||
messages.push(message)
|
||||
}
|
||||
},
|
||||
{} as never,
|
||||
'refresh',
|
||||
)
|
||||
|
||||
expect(messages).toContain('Updated Gitlawb Opengateway models.')
|
||||
expect(messages.join(' ')).not.toContain('inclusionai/ling-3.0-tiny:free')
|
||||
})
|
||||
|
||||
test('/model refresh compares already allowlist-filtered descriptor options', async () => {
|
||||
useSettings({ availableModels: ['local-model-a'] } as SettingsJson)
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
|
||||
@@ -15,6 +15,7 @@ const originalFetch = globalThis.fetch
|
||||
const originalEnv = {
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
|
||||
OPENGATEWAY_API_KEY: process.env.OPENGATEWAY_API_KEY,
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
@@ -94,6 +95,7 @@ afterEach(() => {
|
||||
setClaudeConfigHomeDirForTesting(undefined)
|
||||
restoreEnvValue('CLAUDE_CONFIG_DIR')
|
||||
restoreEnvValue('OPENROUTER_API_KEY')
|
||||
restoreEnvValue('OPENGATEWAY_API_KEY')
|
||||
restoreEnvValue('OPENAI_BASE_URL')
|
||||
restoreEnvValue('OPENAI_API_BASE')
|
||||
restoreEnvValue('OPENAI_API_KEY')
|
||||
@@ -285,15 +287,34 @@ describe('discoverModelsForRoute', () => {
|
||||
test('hybrid routes keep curated descriptor entries ahead of discovered duplicates', async () => {
|
||||
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
|
||||
|
||||
process.env.OPENROUTER_API_KEY = 'or-key'
|
||||
setMockFetch(mock((_input, init) => {
|
||||
expect(init?.headers).toEqual({ Authorization: 'Bearer or-key' })
|
||||
// OpenRouter lists models publicly; discovery no longer requires a key.
|
||||
delete process.env.OPENROUTER_API_KEY
|
||||
const openRouterCalls: Array<{ url: string; headers: unknown }> = []
|
||||
setMockFetch(mock((input, init) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
openRouterCalls.push({ url, headers: init?.headers })
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{ id: 'openai/gpt-5-mini' },
|
||||
{ id: 'anthropic/claude-sonnet-4' },
|
||||
{
|
||||
id: 'openai/gpt-5-mini',
|
||||
name: 'OpenAI: GPT-5 Mini',
|
||||
context_length: 400000,
|
||||
supported_parameters: ['tools'],
|
||||
},
|
||||
{
|
||||
id: 'anthropic/claude-sonnet-4',
|
||||
name: 'Anthropic: Claude Sonnet 4',
|
||||
context_length: 200000,
|
||||
supported_parameters: ['tools', 'reasoning'],
|
||||
},
|
||||
{ id: 'openai/text-embedding-3-large', name: 'Embedding' },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
@@ -305,6 +326,9 @@ describe('discoverModelsForRoute', () => {
|
||||
forceRefresh: true,
|
||||
})
|
||||
|
||||
expect(openRouterCalls).toHaveLength(1)
|
||||
expect(openRouterCalls[0]?.url).toContain('/models')
|
||||
expect(openRouterCalls[0]?.headers).toBeUndefined()
|
||||
expect(result?.models.map((model: { apiName: string }) => model.apiName)).toEqual([
|
||||
'openai/gpt-5-mini',
|
||||
'x-ai/grok-4.6',
|
||||
@@ -312,6 +336,183 @@ describe('discoverModelsForRoute', () => {
|
||||
'anthropic/claude-sonnet-4',
|
||||
])
|
||||
expect(result?.models[0]?.label).toBe('GPT-5 Mini (via OpenRouter)')
|
||||
expect(result?.models[3]?.label).toBe('Anthropic: Claude Sonnet 4')
|
||||
expect(result?.models[3]?.contextWindow).toBe(200000)
|
||||
})
|
||||
|
||||
test('opengateway hybrid discovery loads the live list without a key', async () => {
|
||||
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
|
||||
|
||||
delete process.env.OPENGATEWAY_API_KEY
|
||||
delete process.env.OPENAI_API_KEY
|
||||
delete process.env.OPENAI_API_KEYS
|
||||
const openGatewayCalls: Array<{ url: string; headers: unknown }> = []
|
||||
setMockFetch(mock((input, init) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
openGatewayCalls.push({ url, headers: init?.headers })
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{ id: 'auto', name: 'Auto (smart routing)' },
|
||||
{
|
||||
id: 'xiaomi/mimo-v2.5-pro',
|
||||
name: 'MiMo V2.5-Pro',
|
||||
context_window: 262144,
|
||||
},
|
||||
{
|
||||
id: 'moonshotai/kimi-k3',
|
||||
name: 'Kimi K3',
|
||||
context_window: 128000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
}) as unknown as typeof globalThis.fetch)
|
||||
|
||||
const result = await discoverModelsForRoute('gitlawb-opengateway', {
|
||||
forceRefresh: true,
|
||||
})
|
||||
|
||||
expect(openGatewayCalls).toHaveLength(1)
|
||||
expect(openGatewayCalls[0]?.url).toContain('/v1/models')
|
||||
expect(openGatewayCalls[0]?.headers).toEqual({
|
||||
'Accept-Encoding': 'identity',
|
||||
})
|
||||
expect(result?.source).toBe('network')
|
||||
const apiNames = result?.models.map(
|
||||
(model: { apiName: string }) => model.apiName,
|
||||
)
|
||||
// Curated static entries stay first; live-only routes are appended.
|
||||
expect(apiNames?.[0]).toBe('auto')
|
||||
expect(apiNames).toContain('mimo-v2.5-pro')
|
||||
expect(apiNames).not.toContain('xiaomi/mimo-v2.5-pro')
|
||||
expect(apiNames).toContain('moonshotai/kimi-k3')
|
||||
const liveOnly = result?.models.find(
|
||||
(model: { apiName: string }) => model.apiName === 'moonshotai/kimi-k3',
|
||||
)
|
||||
expect(liveOnly?.label).toBe('Kimi K3')
|
||||
expect(liveOnly?.contextWindow).toBe(128000)
|
||||
})
|
||||
|
||||
test('openrouter discovery preserves credentials and custom headers for overridden base URL', async () => {
|
||||
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
|
||||
|
||||
let capturedUrl: string | undefined
|
||||
let capturedHeaders: unknown
|
||||
setMockFetch(mock((input, init) => {
|
||||
capturedUrl =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
capturedHeaders = init?.headers
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [{ id: 'custom-proxy/model-1' }],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
}) as unknown as typeof globalThis.fetch)
|
||||
|
||||
const result = await discoverModelsForRoute('openrouter', {
|
||||
baseUrl: 'https://proxy.corp.internal/v1',
|
||||
apiKey: 'sk-or-proxy-key',
|
||||
headers: {
|
||||
'X-Proxy-Auth': 'secret-proxy-token',
|
||||
},
|
||||
forceRefresh: true,
|
||||
})
|
||||
|
||||
expect(capturedUrl).toBe('https://proxy.corp.internal/v1/models')
|
||||
expect(capturedHeaders).toEqual({
|
||||
'X-Proxy-Auth': 'secret-proxy-token',
|
||||
Authorization: 'Bearer sk-or-proxy-key',
|
||||
})
|
||||
expect(result?.source).toBe('network')
|
||||
expect(result?.models.some(m => m.apiName === 'custom-proxy/model-1')).toBe(true)
|
||||
})
|
||||
|
||||
test('opengateway discovery preserves credentials and custom headers for overridden base URL', async () => {
|
||||
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
|
||||
|
||||
let capturedUrl: string | undefined
|
||||
let capturedHeaders: unknown
|
||||
setMockFetch(mock((input, init) => {
|
||||
capturedUrl =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
capturedHeaders = init?.headers
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [{ id: 'custom-og/mimo-v3' }],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
}) as unknown as typeof globalThis.fetch)
|
||||
|
||||
const result = await discoverModelsForRoute('gitlawb-opengateway', {
|
||||
baseUrl: 'https://og-proxy.corp.internal/v1',
|
||||
apiKey: 'ogw_live_proxy_key',
|
||||
headers: {
|
||||
'X-Custom-Gate': 'gate-token',
|
||||
},
|
||||
forceRefresh: true,
|
||||
})
|
||||
|
||||
expect(capturedUrl).toBe('https://og-proxy.corp.internal/v1/models')
|
||||
expect(capturedHeaders).toEqual({
|
||||
'Accept-Encoding': 'identity',
|
||||
'X-Custom-Gate': 'gate-token',
|
||||
Authorization: 'Bearer ogw_live_proxy_key',
|
||||
})
|
||||
expect(result?.source).toBe('network')
|
||||
expect(result?.models.some(m => m.apiName === 'custom-og/mimo-v3')).toBe(true)
|
||||
})
|
||||
|
||||
test('opengateway hybrid discovery filters expired static models and live duplicates after availableUntil cutoff', async () => {
|
||||
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
|
||||
|
||||
setMockFetch(mock((input, init) => {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{ id: 'inclusionai/ling-3.0-tiny:free', name: 'Ling 3.0 Tiny Live' },
|
||||
{ id: 'moonshotai/kimi-k3', name: 'Kimi K3' },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
)
|
||||
}) as unknown as typeof globalThis.fetch)
|
||||
|
||||
const result = await discoverModelsForRoute('gitlawb-opengateway', {
|
||||
forceRefresh: true,
|
||||
})
|
||||
|
||||
expect(result?.source).toBe('network')
|
||||
const apiNames = result?.models.map(
|
||||
(model: { apiName: string }) => model.apiName,
|
||||
)
|
||||
expect(apiNames).toContain('mimo-v2.5-pro')
|
||||
expect(apiNames).toContain('moonshotai/kimi-k3')
|
||||
expect(apiNames).not.toContain('inclusionai/ling-3.0-tiny:free')
|
||||
})
|
||||
|
||||
test('openai-compatible discovery applies descriptor static headers with auth', async () => {
|
||||
@@ -413,12 +614,9 @@ describe('discoverModelsForRoute', () => {
|
||||
}) as unknown as typeof globalThis.fetch)
|
||||
|
||||
const result = await discoverModelsForRoute('discovery-no-auth-test', {
|
||||
apiKey: 'discovery-key',
|
||||
forceRefresh: true,
|
||||
})
|
||||
const cached = await discoverModelsForRoute('discovery-no-auth-test', {
|
||||
apiKey: 'different-discovery-key',
|
||||
})
|
||||
const cached = await discoverModelsForRoute('discovery-no-auth-test')
|
||||
|
||||
expect(result?.source).toBe('network')
|
||||
expect(result?.models.map((model: { apiName: string }) => model.apiName)).toEqual(['public-model'])
|
||||
@@ -501,12 +699,6 @@ describe('discoverModelsForRoute', () => {
|
||||
}) as unknown as typeof globalThis.fetch)
|
||||
|
||||
const result = await discoverModelsForRoute('aimlapi', {
|
||||
apiKey: 'should-not-be-sent',
|
||||
headers: {
|
||||
Authorization: 'Bearer should-not-be-sent',
|
||||
'anthropic-api-key': 'should-not-be-sent',
|
||||
'X-Custom-Secret': 'should-not-be-sent',
|
||||
},
|
||||
forceRefresh: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ReadinessProbeKind,
|
||||
} from './descriptors.js'
|
||||
import { resolveRouteIdFromBaseUrl } from './index.js'
|
||||
import { filterAvailableCatalogEntries } from './registry.js'
|
||||
import {
|
||||
getRouteDescriptor,
|
||||
isCanonicalApismartInferenceBaseUrl,
|
||||
@@ -174,10 +175,6 @@ function getRouteDiscoveryApiKey(
|
||||
routeId: string,
|
||||
options?: { baseUrl?: string; apiKey?: string },
|
||||
): string | undefined {
|
||||
if (getRouteCatalog(routeId)?.discovery?.requiresAuth === false) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const baseUrl = getRouteBaseUrl(routeId, options)
|
||||
// ApiSmart's dedicated token must never be used for an overridden discovery
|
||||
// URL. Apply the same exact inference-endpoint boundary used by requests and
|
||||
@@ -254,8 +251,6 @@ export function getRouteDiscoveryHeaders(
|
||||
options?: { baseUrl?: string; headers?: Record<string, string> },
|
||||
): Record<string, string> | undefined {
|
||||
const transportConfig = getRouteDescriptor(routeId)?.transportConfig
|
||||
const acceptsCallerHeaders =
|
||||
getRouteCatalog(routeId)?.discovery?.requiresAuth !== false
|
||||
// Descriptor headers are attribution, not transport plumbing: an `aimlapi`
|
||||
// profile keeps its route id while pointing at a user-controlled proxy, so the
|
||||
// `/models` request must be filtered on the same canonical predicate the
|
||||
@@ -272,7 +267,7 @@ export function getRouteDiscoveryHeaders(
|
||||
getRouteBaseUrl(routeId, options),
|
||||
)
|
||||
: descriptorHeaders),
|
||||
...(acceptsCallerHeaders ? (options?.headers ?? {}) : {}),
|
||||
...(options?.headers ?? {}),
|
||||
}
|
||||
|
||||
return Object.keys(headers).length > 0 ? headers : undefined
|
||||
@@ -410,7 +405,7 @@ export async function discoverModelsForRoute(
|
||||
if (!catalog.discovery) {
|
||||
return {
|
||||
routeId,
|
||||
models: staticEntries,
|
||||
models: filterAvailableCatalogEntries(staticEntries),
|
||||
stale: false,
|
||||
error: null,
|
||||
source: 'static',
|
||||
@@ -430,7 +425,9 @@ export async function discoverModelsForRoute(
|
||||
if (cached) {
|
||||
return {
|
||||
routeId,
|
||||
models: mergeCatalogEntries(staticEntries, cached.models),
|
||||
models: filterAvailableCatalogEntries(
|
||||
mergeCatalogEntries(staticEntries, cached.models),
|
||||
),
|
||||
discoveredModelCount: cached.models.length,
|
||||
stale: false,
|
||||
error: cached.error,
|
||||
@@ -448,7 +445,9 @@ export async function discoverModelsForRoute(
|
||||
const stale = await isCacheStale(cacheKey, ttlMs)
|
||||
return {
|
||||
routeId,
|
||||
models: mergeCatalogEntries(staticEntries, staleEntry.models),
|
||||
models: filterAvailableCatalogEntries(
|
||||
mergeCatalogEntries(staticEntries, staleEntry.models),
|
||||
),
|
||||
discoveredModelCount: staleEntry.models.length,
|
||||
stale,
|
||||
error: staleEntry.error,
|
||||
@@ -458,7 +457,7 @@ export async function discoverModelsForRoute(
|
||||
|
||||
return {
|
||||
routeId,
|
||||
models: staticEntries,
|
||||
models: filterAvailableCatalogEntries(staticEntries),
|
||||
stale: false,
|
||||
error: null,
|
||||
source: 'static',
|
||||
@@ -476,7 +475,9 @@ export async function discoverModelsForRoute(
|
||||
await setCachedModels(discoveryCacheKey, { models: discovered })
|
||||
return {
|
||||
routeId,
|
||||
models: mergeCatalogEntries(staticEntries, discovered),
|
||||
models: filterAvailableCatalogEntries(
|
||||
mergeCatalogEntries(staticEntries, discovered),
|
||||
),
|
||||
discoveredModelCount: discovered.length,
|
||||
stale: false,
|
||||
error: null,
|
||||
@@ -492,7 +493,9 @@ export async function discoverModelsForRoute(
|
||||
if (staleEntry) {
|
||||
return {
|
||||
routeId,
|
||||
models: mergeCatalogEntries(staticEntries, staleEntry.models),
|
||||
models: filterAvailableCatalogEntries(
|
||||
mergeCatalogEntries(staticEntries, staleEntry.models),
|
||||
),
|
||||
discoveredModelCount: staleEntry.models.length,
|
||||
stale: true,
|
||||
error: staleEntry.error,
|
||||
@@ -502,7 +505,7 @@ export async function discoverModelsForRoute(
|
||||
|
||||
return {
|
||||
routeId,
|
||||
models: staticEntries,
|
||||
models: filterAvailableCatalogEntries(staticEntries),
|
||||
stale: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
@@ -536,7 +539,9 @@ export async function refreshStartupDiscoveryForRoute(
|
||||
if (cached) {
|
||||
return {
|
||||
routeId,
|
||||
models: mergeCatalogEntries(getCatalogEntries(routeId), cached.models),
|
||||
models: filterAvailableCatalogEntries(
|
||||
mergeCatalogEntries(getCatalogEntries(routeId), cached.models),
|
||||
),
|
||||
stale: false,
|
||||
error: cached.error,
|
||||
source: 'cache',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import opengateway, { mapOpenGatewayModel } from './gitlawb-opengateway.js'
|
||||
|
||||
describe('gitlawb-opengateway live model mapping', () => {
|
||||
test('uses hybrid discovery against the public models list', () => {
|
||||
expect(opengateway.catalog?.source).toBe('hybrid')
|
||||
expect(opengateway.catalog?.discovery).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'openai-compatible',
|
||||
requiresAuth: false,
|
||||
}),
|
||||
)
|
||||
expect(opengateway.catalog?.discovery?.mapModel).toBe(mapOpenGatewayModel)
|
||||
expect(opengateway.startup?.probeReadiness).toBeUndefined()
|
||||
})
|
||||
|
||||
test('maps gateway routes including auto and free models', () => {
|
||||
expect(
|
||||
mapOpenGatewayModel({
|
||||
id: 'auto',
|
||||
name: 'Auto (smart routing)',
|
||||
description: 'picks the cheapest capable model',
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'auto',
|
||||
apiName: 'auto',
|
||||
label: 'Auto (smart routing)',
|
||||
})
|
||||
|
||||
expect(
|
||||
mapOpenGatewayModel({
|
||||
id: 'xiaomi/mimo-v2.5-pro',
|
||||
name: 'MiMo V2.5-Pro',
|
||||
context_window: 262144,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'mimo-v2.5-pro',
|
||||
apiName: 'mimo-v2.5-pro',
|
||||
label: 'MiMo V2.5-Pro',
|
||||
contextWindow: 262144,
|
||||
})
|
||||
|
||||
expect(
|
||||
mapOpenGatewayModel({
|
||||
id: 'nvidia/nemotron-3-ultra-550b-a55b:free',
|
||||
name: 'Nemotron 3 Ultra free',
|
||||
context_window: 128000,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'nvidia/nemotron-3-ultra-550b-a55b:free',
|
||||
apiName: 'nvidia/nemotron-3-ultra-550b-a55b:free',
|
||||
label: 'Nemotron 3 Ultra free',
|
||||
contextWindow: 128000,
|
||||
notes: 'Free',
|
||||
})
|
||||
})
|
||||
|
||||
test('drops known non-coding ids', () => {
|
||||
expect(
|
||||
mapOpenGatewayModel({
|
||||
id: 'whisper-1',
|
||||
name: 'Whisper',
|
||||
}),
|
||||
).toBeNull()
|
||||
expect(mapOpenGatewayModel({})).toBeNull()
|
||||
expect(mapOpenGatewayModel({ id: ' ' })).toBeNull()
|
||||
expect(mapOpenGatewayModel(null)).toBeNull()
|
||||
})
|
||||
|
||||
test('falls back to display_name and title for labels', () => {
|
||||
expect(mapOpenGatewayModel({ id: 'a/b', display_name: 'B' })?.label).toBe(
|
||||
'B',
|
||||
)
|
||||
expect(mapOpenGatewayModel({ id: 'c/d', title: 'D' })?.label).toBe('D')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,64 @@
|
||||
import { defineGateway } from '../define.js'
|
||||
import type { ModelCatalogEntry } from '../descriptors.js'
|
||||
import { ZAI_GLM_OPENAI_SHIM } from '../transport/zaiGlmShim.js'
|
||||
import {
|
||||
firstPositiveNumber,
|
||||
getTrimmedString,
|
||||
isFreeModel,
|
||||
isKnownNonCodingModelId,
|
||||
isRecord,
|
||||
} from '../modelMapping.js'
|
||||
|
||||
/**
|
||||
* Normalizes OpenGateway model IDs by removing the `xiaomi/` prefix when the
|
||||
* remainder starts with `mimo` (the gateway exposes some Xiaomi models both
|
||||
* with and without the vendor prefix; we keep the shorter form for the catalog).
|
||||
*/
|
||||
function normalizeOpenGatewayModelId(id: string): string {
|
||||
return id.replace(/^xiaomi\/(?=mimo(?:-|$))/i, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OpenGateway's public GET /v1/models payload into a catalog entry.
|
||||
* The gateway already curates what it exposes, so every non-empty id is kept
|
||||
* except clearly non-coding names if they ever appear.
|
||||
*/
|
||||
export function mapOpenGatewayModel(raw: unknown): ModelCatalogEntry | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawId = getTrimmedString(raw, 'id')
|
||||
if (!rawId || isKnownNonCodingModelId(rawId)) {
|
||||
return null
|
||||
}
|
||||
const id = normalizeOpenGatewayModelId(rawId)
|
||||
|
||||
const name =
|
||||
getTrimmedString(raw, 'name') ||
|
||||
getTrimmedString(raw, 'display_name') ||
|
||||
getTrimmedString(raw, 'title')
|
||||
const free = isFreeModel(id, raw)
|
||||
let label = name || id
|
||||
if (free && !label.toLowerCase().includes('free')) {
|
||||
label = `${label} (free)`
|
||||
}
|
||||
|
||||
const contextWindow = firstPositiveNumber(
|
||||
raw.context_window,
|
||||
raw.contextWindow,
|
||||
raw.context_length,
|
||||
raw.max_context_length,
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
apiName: id,
|
||||
label,
|
||||
...(contextWindow ? { contextWindow } : {}),
|
||||
...(free ? { notes: 'Free' } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export default defineGateway({
|
||||
id: 'gitlawb-opengateway',
|
||||
@@ -62,7 +121,18 @@ export default defineGateway({
|
||||
fallbackModel: 'mimo-v2.5-pro',
|
||||
},
|
||||
catalog: {
|
||||
source: 'static',
|
||||
// Hybrid: curated defaults stay first for labels/descriptor links; live
|
||||
// GET /v1/models fills in new gateway routes without a catalog PR.
|
||||
source: 'hybrid',
|
||||
discovery: {
|
||||
kind: 'openai-compatible',
|
||||
// Public model list works without a key (chat still requires auth).
|
||||
requiresAuth: false,
|
||||
mapModel: mapOpenGatewayModel,
|
||||
},
|
||||
discoveryCacheTtl: '1d',
|
||||
discoveryRefreshMode: 'startup',
|
||||
allowManualRefresh: true,
|
||||
models: [
|
||||
// Virtual model: the gateway's smart router picks the cheapest model
|
||||
// expected to handle the request and escalates on upstream failure
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import openrouter, { mapOpenRouterModel } from './openrouter.js'
|
||||
|
||||
describe('openrouter gateway live model mapping', () => {
|
||||
test('uses hybrid discovery against the public models list', () => {
|
||||
expect(openrouter.catalog?.source).toBe('hybrid')
|
||||
expect(openrouter.catalog?.discovery).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'openai-compatible',
|
||||
requiresAuth: false,
|
||||
}),
|
||||
)
|
||||
expect(openrouter.catalog?.discovery?.mapModel).toBe(mapOpenRouterModel)
|
||||
})
|
||||
|
||||
test('maps coding models with context length and free labels', () => {
|
||||
expect(
|
||||
mapOpenRouterModel({
|
||||
id: 'anthropic/claude-sonnet-4.5',
|
||||
name: 'Anthropic: Claude Sonnet 4.5',
|
||||
description: 'A long marketing blurb.',
|
||||
context_length: 200000,
|
||||
architecture: {
|
||||
input_modalities: ['text', 'image'],
|
||||
output_modalities: ['text'],
|
||||
},
|
||||
supported_parameters: ['tools', 'reasoning', 'temperature'],
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'anthropic/claude-sonnet-4.5',
|
||||
apiName: 'anthropic/claude-sonnet-4.5',
|
||||
label: 'Anthropic: Claude Sonnet 4.5',
|
||||
contextWindow: 200000,
|
||||
capabilities: {
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
mapOpenRouterModel({
|
||||
id: 'nvidia/nemotron-3-ultra:free',
|
||||
name: 'Nemotron 3 Ultra',
|
||||
context_length: 128000,
|
||||
supported_parameters: ['tools'],
|
||||
is_free: true,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'nvidia/nemotron-3-ultra:free',
|
||||
apiName: 'nvidia/nemotron-3-ultra:free',
|
||||
label: 'Nemotron 3 Ultra (free)',
|
||||
contextWindow: 128000,
|
||||
notes: 'Free',
|
||||
capabilities: {
|
||||
supportsFunctionCalling: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('filters non-coding and non-text routes', () => {
|
||||
expect(
|
||||
mapOpenRouterModel({
|
||||
id: 'openai/text-embedding-3-large',
|
||||
name: 'Embedding',
|
||||
}),
|
||||
).toBeNull()
|
||||
|
||||
expect(
|
||||
mapOpenRouterModel({
|
||||
id: 'vendor/image-only',
|
||||
name: 'Image Only',
|
||||
architecture: {
|
||||
input_modalities: ['text'],
|
||||
output_modalities: ['image'],
|
||||
},
|
||||
}),
|
||||
).toBeNull()
|
||||
|
||||
expect(mapOpenRouterModel({})).toBeNull()
|
||||
expect(mapOpenRouterModel({ id: ' ' })).toBeNull()
|
||||
expect(mapOpenRouterModel(null)).toBeNull()
|
||||
})
|
||||
|
||||
test('keeps unfamiliar text models without declared parameters', () => {
|
||||
expect(
|
||||
mapOpenRouterModel({ id: 'vendor/brand-new-1', name: 'New' }),
|
||||
).toEqual({
|
||||
id: 'vendor/brand-new-1',
|
||||
apiName: 'vendor/brand-new-1',
|
||||
label: 'New',
|
||||
})
|
||||
})
|
||||
|
||||
test('detects reasoning from the reasoning object', () => {
|
||||
expect(
|
||||
mapOpenRouterModel({
|
||||
id: 'vendor/brand-new-1',
|
||||
name: 'New',
|
||||
reasoning: { supported_efforts: ['low', 'high'] },
|
||||
})?.capabilities,
|
||||
).toEqual({ supportsReasoning: true })
|
||||
})
|
||||
|
||||
test('keeps text models with deep-research in their ID', () => {
|
||||
expect(
|
||||
mapOpenRouterModel({
|
||||
id: 'perplexity/sonar-deep-research',
|
||||
name: 'Perplexity: Sonar Deep Research',
|
||||
context_length: 128000,
|
||||
architecture: {
|
||||
input_modalities: ['text'],
|
||||
output_modalities: ['text'],
|
||||
},
|
||||
supported_parameters: ['tools', 'reasoning'],
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'perplexity/sonar-deep-research',
|
||||
apiName: 'perplexity/sonar-deep-research',
|
||||
label: 'Perplexity: Sonar Deep Research',
|
||||
contextWindow: 128000,
|
||||
capabilities: {
|
||||
supportsFunctionCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,112 @@
|
||||
import { defineGateway } from '../define.js'
|
||||
import type { ModelCatalogEntry } from '../descriptors.js'
|
||||
import {
|
||||
firstPositiveNumber,
|
||||
getTrimmedString,
|
||||
isFreeModel,
|
||||
isKnownNonCodingModelId,
|
||||
isRecord,
|
||||
} from '../modelMapping.js'
|
||||
|
||||
/**
|
||||
* Checks if the raw model payload declares support for tool/function calling
|
||||
* via the `supported_parameters` array.
|
||||
*/
|
||||
function supportsTools(raw: Record<string, unknown>): boolean {
|
||||
const params = raw.supported_parameters
|
||||
return Array.isArray(params) && params.some(value => value === 'tools')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the raw model payload declares reasoning support.
|
||||
* Recognizes `reasoning`, `reasoning_effort`, `include_reasoning` in
|
||||
* `supported_parameters`, or a `reasoning` object with mandatory/default/enabled
|
||||
* flags or a non-empty `supported_efforts` array.
|
||||
*/
|
||||
function supportsReasoning(raw: Record<string, unknown>): boolean {
|
||||
const params = raw.supported_parameters
|
||||
if (
|
||||
Array.isArray(params) &&
|
||||
params.some(
|
||||
value =>
|
||||
value === 'reasoning' ||
|
||||
value === 'reasoning_effort' ||
|
||||
value === 'include_reasoning',
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const reasoning = raw.reasoning
|
||||
if (reasoning === true) {
|
||||
return true
|
||||
}
|
||||
if (isRecord(reasoning)) {
|
||||
return (
|
||||
reasoning.mandatory === true ||
|
||||
reasoning.default_enabled === true ||
|
||||
(Array.isArray(reasoning.supported_efforts) &&
|
||||
reasoning.supported_efforts.length > 0)
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OpenRouter's public GET /api/v1/models payload into a catalog entry.
|
||||
* Keeps coding-capable chat models and drops embeddings/image/audio routes.
|
||||
*/
|
||||
export function mapOpenRouterModel(raw: unknown): ModelCatalogEntry | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = getTrimmedString(raw, 'id')
|
||||
if (!id || isKnownNonCodingModelId(id)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const architecture = isRecord(raw.architecture) ? raw.architecture : null
|
||||
const outputModalities = Array.isArray(architecture?.output_modalities)
|
||||
? architecture.output_modalities.filter(
|
||||
(value): value is string => typeof value === 'string',
|
||||
)
|
||||
: []
|
||||
if (outputModalities.length > 0 && !outputModalities.includes('text')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toolCall = supportsTools(raw)
|
||||
const reasoning = supportsReasoning(raw)
|
||||
const name = getTrimmedString(raw, 'name')
|
||||
const free = isFreeModel(id, raw)
|
||||
let label = name || id
|
||||
if (free && !label.toLowerCase().includes('free')) {
|
||||
label = `${label} (free)`
|
||||
}
|
||||
|
||||
const contextWindow = firstPositiveNumber(
|
||||
raw.context_length,
|
||||
raw.max_context_length,
|
||||
raw.context_window,
|
||||
raw.contextWindow,
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
apiName: id,
|
||||
label,
|
||||
...(contextWindow ? { contextWindow } : {}),
|
||||
...(free ? { notes: 'Free' } : {}),
|
||||
...(toolCall || reasoning
|
||||
? {
|
||||
capabilities: {
|
||||
...(toolCall ? { supportsFunctionCalling: true } : {}),
|
||||
...(reasoning ? { supportsReasoning: true } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
export default defineGateway({
|
||||
id: 'openrouter',
|
||||
@@ -29,7 +137,12 @@ export default defineGateway({
|
||||
},
|
||||
catalog: {
|
||||
source: 'hybrid',
|
||||
discovery: { kind: 'openai-compatible' },
|
||||
discovery: {
|
||||
kind: 'openai-compatible',
|
||||
// Public model list works without a key (same posture as cairn-code).
|
||||
requiresAuth: false,
|
||||
mapModel: mapOpenRouterModel,
|
||||
},
|
||||
discoveryCacheTtl: '1d',
|
||||
discoveryRefreshMode: 'background-if-stale',
|
||||
allowManualRefresh: true,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Type guard: returns true when the value is a non-null object.
|
||||
* Used to safely narrow unknown payloads before reading keys.
|
||||
*/
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a trimmed string from a record, or undefined if the key is
|
||||
* missing or the value is not a string. Trims whitespace from both ends.
|
||||
*/
|
||||
export function getTrimmedString(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = record[key]
|
||||
return typeof value === 'string' ? value.trim() : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first finite positive number from the variadic arguments.
|
||||
* Skips non-numbers, NaN, Infinity, and values <= 0.
|
||||
*/
|
||||
export function firstPositiveNumber(...values: unknown[]): number | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic check for model IDs that are clearly not coding-capable chat models.
|
||||
* Matches common embedding, audio, image, moderation, and speech model name patterns.
|
||||
*/
|
||||
export function isKnownNonCodingModelId(id: string): boolean {
|
||||
return /(audio|dall-e|embedding|image|moderation|realtime|rerank|sora|speech|transcribe|translate|tts|whisper)/i.test(
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether a model is free-tier based on common OpenRouter/OpenGateway
|
||||
* conventions: an ID ending with `:free`, or a `free`/`is_free` boolean flag
|
||||
* in the raw payload.
|
||||
*/
|
||||
export function isFreeModel(
|
||||
id: string,
|
||||
raw: Record<string, unknown>,
|
||||
): boolean {
|
||||
return (
|
||||
id.toLowerCase().endsWith(':free') ||
|
||||
raw.free === true ||
|
||||
raw.is_free === true
|
||||
)
|
||||
}
|
||||
@@ -1161,4 +1161,43 @@ describe('resolveOpenAIShimRuntimeContext - segment-boundary heuristic', () => {
|
||||
}).contextWindow,
|
||||
).toBe(262_144)
|
||||
})
|
||||
|
||||
it('preserves OpenGateway maxTokensField wire contract for live-only inferred models', () => {
|
||||
for (const model of ['moonshotai/kimi-k3', 'deepseek/deepseek-r1', 'z-ai/glm-5.2']) {
|
||||
const result = resolveOpenAIShimRuntimeContext({
|
||||
baseUrl: 'https://opengateway.gitlawb.com/v1',
|
||||
model,
|
||||
processEnv: { CLAUDE_CODE_USE_OPENAI: '1' },
|
||||
})
|
||||
expect(result.routeId).toBe('gitlawb-opengateway')
|
||||
expect(result.openaiShimConfig.maxTokensField).toBe('max_completion_tokens')
|
||||
expect(result.openaiShimConfig.preserveReasoningContent).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('prefers explicit descriptor and catalog openaiShim overrides over inferred settings and merges removeBodyFields', () => {
|
||||
// Inferred GLM shim defaults maxTokensField to 'max_tokens' and removeBodyFields to ['store'].
|
||||
// OpenGateway route descriptor explicitly sets maxTokensField to 'max_completion_tokens'
|
||||
// and removeBodyFields to ['store', 'stream_options'].
|
||||
const opengatewayGlm = resolveOpenAIShimRuntimeContext({
|
||||
baseUrl: 'https://opengateway.gitlawb.com/v1',
|
||||
model: 'z-ai/glm-5.2',
|
||||
processEnv: { CLAUDE_CODE_USE_OPENAI: '1' },
|
||||
})
|
||||
expect(opengatewayGlm.openaiShimConfig.maxTokensField).toBe('max_completion_tokens')
|
||||
expect(opengatewayGlm.openaiShimConfig.removeBodyFields).toEqual([
|
||||
'store',
|
||||
'stream_options',
|
||||
])
|
||||
expect(opengatewayGlm.openaiShimConfig.preserveReasoningContent).toBe(true)
|
||||
|
||||
// Atlas Cloud grok-build-0.1 catalog entry explicitly sets removeBodyFields: ['reasoning_effort']
|
||||
// which merges with any route-level settings.
|
||||
const atlasGrok = resolveOpenAIShimRuntimeContext({
|
||||
baseUrl: 'https://api.atlascloud.ai/v1',
|
||||
model: 'xai/grok-build-0.1',
|
||||
processEnv: { CLAUDE_CODE_USE_OPENAI: '1' },
|
||||
})
|
||||
expect(atlasGrok.openaiShimConfig.removeBodyFields).toContain('reasoning_effort')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,9 +144,9 @@ function mergeOpenAIShimConfig(
|
||||
inferredConfig: Partial<OpenAIShimTransportConfig> | undefined,
|
||||
): OpenAIShimTransportConfig {
|
||||
return {
|
||||
...inferredConfig,
|
||||
...baseConfig,
|
||||
...entryConfig,
|
||||
...inferredConfig,
|
||||
removeBodyFields: mergeRemoveBodyFields(
|
||||
baseConfig?.removeBodyFields,
|
||||
entryConfig?.removeBodyFields,
|
||||
|
||||
@@ -154,7 +154,7 @@ test('local OpenAI bootstrap falls back when route discovery has only static mod
|
||||
}
|
||||
})
|
||||
|
||||
test('AIMLAPI discovery omits credentials on the public /models route', async () => {
|
||||
test('AIMLAPI discovery passes credentials and headers on the bootstrap route', async () => {
|
||||
const envKeys = [
|
||||
'ANTHROPIC_CUSTOM_HEADERS',
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
@@ -211,11 +211,12 @@ test('AIMLAPI discovery omits credentials on the public /models route', async ()
|
||||
},
|
||||
})
|
||||
|
||||
// Public `/models`: no apiKey and no env-sourced headers reach the probe;
|
||||
// only the route's attribution headers ride along on the fallback.
|
||||
expect(discoveryOptions?.apiKey).toBeUndefined()
|
||||
expect(discoveryOptions?.headers).toBeUndefined()
|
||||
expect(fallbackOptions?.apiKey).toBeUndefined()
|
||||
expect(discoveryOptions?.apiKey).toBe('sk-aimlapi-test')
|
||||
expect(discoveryOptions?.headers).toEqual({
|
||||
Authorization: 'Bearer leaked',
|
||||
'X-API-Key': 'leaked-key',
|
||||
})
|
||||
expect(fallbackOptions?.apiKey).toBe('sk-aimlapi-test')
|
||||
expect(fallbackOptions?.headers).toEqual({
|
||||
'X-AIMLAPI-Source': 'agent/openclaude',
|
||||
'X-AIMLAPI-Partner-ID': 'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
|
||||
@@ -223,6 +224,8 @@ test('AIMLAPI discovery omits credentials on the public /models route', async ()
|
||||
'X-AIMLAPI-Integration-Version': publicBuildVersion,
|
||||
'HTTP-Referer': 'OpenClaude',
|
||||
'X-Title': 'OpenClaude',
|
||||
Authorization: 'Bearer leaked',
|
||||
'X-API-Key': 'leaked-key',
|
||||
})
|
||||
} finally {
|
||||
for (const key of envKeys) {
|
||||
@@ -235,3 +238,64 @@ test('AIMLAPI discovery omits credentials on the public /models route', async ()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('OpenGateway discovery filters expired models from bootstrap additionalModelOptions', async () => {
|
||||
const envKeys = [
|
||||
'ANTHROPIC_CUSTOM_HEADERS',
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'OPENAI_API_KEY',
|
||||
'OPENAI_API_KEYS',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENAI_MODEL',
|
||||
'OPENGATEWAY_API_KEY',
|
||||
] as const
|
||||
const savedEnv = new Map<string, string | undefined>(
|
||||
envKeys.map(key => [key, process.env[key]]),
|
||||
)
|
||||
|
||||
try {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = 'https://opengateway.gitlawb.com/v1'
|
||||
process.env.OPENAI_MODEL = 'auto'
|
||||
delete process.env.OPENAI_API_KEY
|
||||
delete process.env.OPENAI_API_KEYS
|
||||
delete process.env.OPENGATEWAY_API_KEY
|
||||
|
||||
const payload = await fetchLocalOpenAIModelOptions({
|
||||
getAdditionalModelOptionsCacheScope: () =>
|
||||
'openai:https://opengateway.gitlawb.com/v1',
|
||||
resolveProviderRequest: () =>
|
||||
({
|
||||
baseUrl: 'https://opengateway.gitlawb.com/v1',
|
||||
}) as ReturnType<typeof import('./providerConfig.js').resolveProviderRequest>,
|
||||
discoverModelsForRoute: async () => ({
|
||||
routeId: 'gitlawb-opengateway',
|
||||
models: [
|
||||
{ id: 'mimo-v2.5-pro', apiName: 'mimo-v2.5-pro', label: 'MiMo V2.5 Pro' },
|
||||
{ id: 'moonshotai/kimi-k3', apiName: 'moonshotai/kimi-k3', label: 'Kimi K3' },
|
||||
],
|
||||
discoveredModelCount: 2,
|
||||
stale: false,
|
||||
error: null,
|
||||
source: 'network',
|
||||
}),
|
||||
listOpenAICompatibleModels: async () => ['mimo-v2.5-pro', 'moonshotai/kimi-k3'],
|
||||
})
|
||||
|
||||
const modelValues = payload?.additionalModelOptions.map(opt => opt.value)
|
||||
expect(modelValues).toContain('mimo-v2.5-pro')
|
||||
expect(modelValues).toContain('moonshotai/kimi-k3')
|
||||
expect(modelValues).not.toContain('inclusionai/ling-3.0-tiny:free')
|
||||
} finally {
|
||||
for (const key of envKeys) {
|
||||
const value = savedEnv.get(key)
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -261,35 +261,27 @@ export async function fetchLocalOpenAIModelOptions(
|
||||
? undefined
|
||||
: firstUsableCredential(routeCredential)
|
||||
|
||||
// Routes whose catalog declares a public `/models` (discovery.requiresAuth:
|
||||
// false) must not receive credentials on the background probe: drop the
|
||||
// apiKey and any env-sourced custom headers, keeping only the route's own
|
||||
// attribution headers.
|
||||
const discoveryRequiresAuth =
|
||||
!routeId ||
|
||||
getRouteDescriptor(routeId)?.catalog?.discovery?.requiresAuth !== false
|
||||
const discoveryApiKey = discoveryRequiresAuth ? apiKey : undefined
|
||||
const discoveryHeaders = discoveryRequiresAuth
|
||||
? parseCustomHeadersEnv(process.env.ANTHROPIC_CUSTOM_HEADERS)
|
||||
: undefined
|
||||
const customHeaders = parseCustomHeadersEnv(
|
||||
process.env.ANTHROPIC_CUSTOM_HEADERS,
|
||||
)
|
||||
const fallbackHeaders = routeId
|
||||
? getRouteDiscoveryHeaders(routeId, { baseUrl, headers: discoveryHeaders })
|
||||
: discoveryHeaders
|
||||
? getRouteDiscoveryHeaders(routeId, { baseUrl, headers: customHeaders })
|
||||
: customHeaders
|
||||
|
||||
const discoverModels = deps.discoverModelsForRoute ?? discoverModelsForRoute
|
||||
const listModels = deps.listOpenAICompatibleModels ?? listOpenAICompatibleModels
|
||||
const discovered = routeId
|
||||
? await discoverModels(routeId, {
|
||||
baseUrl,
|
||||
apiKey: discoveryApiKey,
|
||||
headers: discoveryHeaders,
|
||||
apiKey,
|
||||
headers: customHeaders,
|
||||
})
|
||||
: null
|
||||
const models =
|
||||
getDiscoveredModelApiNames(discovered) ??
|
||||
(await listModels({
|
||||
baseUrl,
|
||||
apiKey: discoveryApiKey,
|
||||
apiKey,
|
||||
headers: fallbackHeaders,
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { APIError } from '@anthropic-ai/sdk'
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
|
||||
import { setClaudeConfigHomeDirForTesting } from '../../utils/envUtils.js'
|
||||
import {
|
||||
classifyAPIError,
|
||||
getAssistantMessageFromError,
|
||||
@@ -18,8 +22,20 @@ function getFirstText(message: ReturnType<typeof getAssistantMessageFromError>):
|
||||
}
|
||||
|
||||
const originalBaseUrl = process.env.OPENAI_BASE_URL
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'openclaude-opencode-go-error-test-'))
|
||||
setClaudeConfigHomeDirForTesting(tempDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setClaudeConfigHomeDirForTesting(undefined)
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (originalBaseUrl === undefined) {
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user