feat(opengateway): add Ling 3.0 Tiny :free — Day-0 launch, free until August 13 (#2112)

* feat(opengateway): add Ling 3.0 Tiny :free — Day-0 launch, free until Aug 13

inclusionai/ling-3.0-tiny:free (7.9B MoE, ~1.3B active, 262k ctx)
joins the picker via the gateway's OpenRouter wiring. The gateway
time-boxes it (free through Aug 13, rate limited) and delists it
server-side when the window closes.

* test(opengateway): Ling Tiny gateway mapping test + explicit window note

Addresses CodeRabbit review on #2112:
- new ling-tiny.test.ts (macaron.test.ts pattern) asserting the
  opengateway-ling-3.0-tiny-free entry maps both apiName and
  modelDescriptorId to inclusionai/ling-3.0-tiny:free, plus descriptor
  capabilities and runtime limits
- catalog note now dated explicitly ('Free through August 13, 2026')
  and the entry's lifecycle documented: the gateway time-boxes the id
  server-side and 400s after the window; this static catalog has no
  expiry mechanism (Ling Flash precedent), so the entry is removed or
  updated at window close

* feat(integrations): availableUntil catalog-entry expiry + Tiny lifecycle guard

Addresses CodeRabbit round 2 on #2112:
- new optional ModelCatalogEntry.availableUntil (ISO-8601): entries past
  the cutoff are dropped in getCatalogEntriesForRoute, the single choke
  point behind the model picker, gateway catalogs, and runtime limits;
  the pre-existing (previously unenforced) hidden flag is honored in the
  same filter; unparseable dates fail open
- the Ling Tiny entry sets availableUntil to the gateway's window end
  (2026-08-13T10:00:00Z), so the picker drops it the instant the
  gateway starts rejecting the id — no client release needed
- boundary regression test on both sides of the cutoff, and the picker
  expected-list test pins the clock inside the window (setSystemTime)
  so it stays deterministic after the date passes
- ling-tiny.test.ts now also asserts supportsPreciseTokenCount: false

* test(integrations): exact-cutoff + hidden + malformed-date coverage; fix stale lifecycle comment

Addresses CodeRabbit round 3 on #2112:
- ling-tiny.test.ts asserts the boundary at exactly 2026-08-13T10:00:00Z
  (cutoff is exclusive: entry already gone at that instant)
- registry.test.ts covers the two previously-untested filter branches:
  hidden entries dropped, availableUntil expiry (before / at / after
  cutoff), and a malformed availableUntil failing open
- the catalog comment above the Ling Tiny entry no longer claims the
  static catalog has no expiry mechanism — availableUntil is the guard

Validation commands run locally:
  bun run integrations:generate
  bun test src/integrations src/commands/model/model.test.tsx src/utils/model
  bunx tsc --noEmit
558 tests pass, typecheck clean.

* fix(model): route static picker entries through the availability filter

Addresses jatmn's P1 on #2112: model.tsx read catalog.models directly,
bypassing the availableUntil/hidden filter that only lived in
getCatalogEntriesForRoute — so after 2026-08-13T10:00Z the /model
picker would still offer inclusionai/ling-3.0-tiny:free and selecting
it would persist an id the gateway 400s.

- registry.ts exports filterAvailableCatalogEntries (shared with
  getCatalogEntriesForRoute)
- model.tsx filters the static entries AND the static+discovery merged
  list, so discovery-sourced entries with their own markers are covered
- routeMetadata.ts getRouteDefaultModel's catalog fallback filters too,
  so an expired entry can never become the implicit default
- new picker regression test pinned just past the cutoff asserts the
  expired entry is gone while the rest of the catalog is untouched

Validation: bun run integrations:generate; bun test src/integrations
src/commands/model/model.test.tsx src/utils/model (559 pass); bunx tsc
--noEmit (clean).

* fix(model): merge raw static entries so expired ones mask cached duplicates

Addresses CodeRabbit round 4 on #2112: filtering static entries before
mergeRouteCatalogEntries let a cached discovery entry with the same
apiName (and no availableUntil marker) re-enter the merged list, where
the post-merge filter could not remove it. The merge now takes the RAW
static list — the expired static entry wins the apiName dedup and the
post-merge filter then drops it, so neither copy survives. The filtered
list still drives the non-discovery path.

Regression tests in routeCatalogOptions.test.ts cover the cached
duplicate after the cutoff (including documenting the buggy pre-filter
order) and the masking inside the window.

Validation: bun run integrations:generate; bun test src/integrations
src/commands/model/model.test.tsx src/utils/model (561 pass); bunx tsc
--noEmit (clean).

* test(integrations): default-model fallback skips hidden and expired entries

Addresses CodeRabbit round 5 on #2112: getRouteDefaultModel's catalog
fallback changed in the availability-filter fix but had no focused
coverage. New routeMetadata.test.ts case (self-contained registry
mutation with the shared lock, mirroring registry.test.ts) verifies a
hidden default-marked entry and a past-cutoff availableUntil entry are
both skipped in favor of the remaining valid entry, and that a catalog
with nothing valid yields undefined rather than a rejected id.

Validation: bun test ./src/integrations/routeMetadata.test.ts (63
pass); bun run integrations:generate; bun test src/integrations
src/commands/model/model.test.tsx src/utils/model (562 pass); bunx tsc
--noEmit (clean).

* test(integrations): release shared mutation lock even if registry restore throws

Addresses CodeRabbit round 6 on #2112: the fallback test's finally
block ran _clearRegistryForTesting/ensureIntegrationsLoaded before
releaseSharedMutationLock, so a throw there would leave the lock held
and block later tests. Nested try/finally, matching registry.test.ts's
afterEach shape.

Validation: bun test ./src/integrations/routeMetadata.test.ts (63
pass); full related suites 562 pass; tsc clean.

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
This commit is contained in:
Kevin Codex
2026-08-11 20:18:40 +08:00
committed by GitHub
co-authored by OpenClaude
parent 16e332e108
commit 7b03ad19a4
13 changed files with 452 additions and 42 deletions
+87 -26
View File
@@ -1,6 +1,6 @@
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
import { afterEach, beforeEach, expect, mock, setSystemTime, test } from 'bun:test'
import React from 'react'
import { getAdditionalModelOptionsCacheScope } from '../../services/api/providerConfig.js'
@@ -1429,33 +1429,94 @@ test('/model applies auto provider surface for single-model static descriptor pr
getActiveProviderProfile: () => activeProfile,
})
const rendered = await renderModelCommandWithCapturedPicker(
'descriptor-picker-auto-provider-static-mode',
)
// Pin the clock inside the Ling Tiny availability window (its catalog
// entry expires via availableUntil on 2026-08-13T10:00Z) so this expected
// list stays deterministic after that date passes in real time.
setSystemTime(new Date('2026-08-12T00:00:00Z'))
try {
expect(
(rendered.getCapturedProps().optionsOverride as ModelOption[]).map(
option => option.value,
),
).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',
])
const rendered = await renderModelCommandWithCapturedPicker(
'descriptor-picker-auto-provider-static-mode',
)
try {
expect(
(rendered.getCapturedProps().optionsOverride as ModelOption[]).map(
option => option.value,
),
).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',
'inclusionai/ling-3.0-tiny:free',
'mindai/macaron-v1-tall',
'mindai/macaron-v1-venti',
'tencent/hy3',
])
} finally {
rendered.instance.unmount()
rendered.stdout.end()
}
} finally {
rendered.instance.unmount()
rendered.stdout.end()
setSystemTime()
}
})
test('/model drops expired availableUntil entries from the static picker after the cutoff', 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,
})
// Just past the Ling Tiny window (availableUntil 2026-08-13T10:00Z): the
// picker must not offer the id the gateway now rejects with a 400.
setSystemTime(new Date('2026-08-13T10:00:01Z'))
try {
const rendered = await renderModelCommandWithCapturedPicker(
'descriptor-picker-expired-entry-hidden',
)
try {
const values = (
rendered.getCapturedProps().optionsOverride as ModelOption[]
).map(option => option.value)
expect(values).not.toContain('inclusionai/ling-3.0-tiny:free')
// The rest of the static catalog is untouched by the expiry.
expect(values).toContain('inclusionai/ling-3.0-flash')
expect(values).toContain('nvidia/nemotron-3-ultra-550b-a55b:free')
} finally {
rendered.instance.unmount()
rendered.stdout.end()
}
} finally {
setSystemTime()
}
})
+18 -4
View File
@@ -13,6 +13,7 @@ import {
parseDurationString,
} from '../../integrations/discoveryCache.js'
import type { ModelCatalogConfig } from '../../integrations/descriptors.js'
import { filterAvailableCatalogEntries } from '../../integrations/index.js'
import {
discoverModelsForRoute,
getDiscoveryCacheKey,
@@ -457,7 +458,16 @@ async function loadDescriptorDiscoveryContext(
routeId,
settingsMode: getProviderProfileModelPickerMode(),
})
const staticEntries = catalog.models ?? []
// Availability-filter the static entries (hidden / availableUntil) — this
// path reads the descriptor's catalog directly, so it must apply the same
// filter as getCatalogEntriesForRoute or an expired time-boxed entry
// (e.g. a closed free window) stays selectable in the picker. The RAW list
// is kept alongside: the static+discovery merge below dedupes by apiName
// with static entries winning, so the expired static entry must still be
// present there to block a cached discovery duplicate (which would carry
// no availableUntil marker and survive the post-merge filter).
const rawStaticEntries = catalog.models ?? []
const staticEntries = filterAvailableCatalogEntries(rawStaticEntries)
const trafficRestricted = isEssentialTrafficOnly()
const canRefresh = Boolean(
catalog.discovery && catalog.allowManualRefresh && !trafficRestricted,
@@ -499,9 +509,13 @@ async function loadDescriptorDiscoveryContext(
staticEntryCount: staticEntries.length,
stale,
}) && !trafficRestricted
const mergedEntries = mergeRouteCatalogEntries(
staticEntries,
cached?.models ?? [],
// Merge the RAW static list (see above), then filter: the expired static
// entry wins the apiName dedup against any cached discovery duplicate, and
// the post-merge filter removes it — so neither copy survives. Filtering
// after the merge also covers discovery entries carrying their own
// hidden/availableUntil markers (mapModel).
const mergedEntries = filterAvailableCatalogEntries(
mergeRouteCatalogEntries(rawStaticEntries, cached?.models ?? []),
)
let discoveryState: ModelPickerDiscoveryState | undefined
+1 -1
View File
@@ -12,5 +12,5 @@ export default defineBrand({
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
modelIds: ['inclusionai/ling-3.0-flash'],
modelIds: ['inclusionai/ling-3.0-flash', 'inclusionai/ling-3.0-tiny:free'],
})
+8
View File
@@ -114,6 +114,14 @@ export interface ModelCatalogEntry {
label?: string
default?: boolean
hidden?: boolean
/**
* ISO-8601 instant after which the entry is dropped from catalog
* resolution (model picker, gateway catalogs, runtime limits). For
* time-boxed launches — e.g. a free window the gateway delists
* server-side — so the picker never offers a model the gateway will
* reject. An unparseable date fails open (the entry stays visible).
*/
availableUntil?: string
modelDescriptorId?: string
capabilities?: CapabilityFlags
reasoning?: ReasoningControlMetadata
@@ -154,6 +154,21 @@ export default defineGateway({
label: 'Ling 3.0 Flash (via Opengateway)',
modelDescriptorId: 'inclusionai/ling-3.0-flash',
},
// Day-0 Novita launch via the gateway's OpenRouter wiring. Lifecycle:
// the gateway time-boxes the id server-side (LING_TINY_FREE_END_ISO in
// opengateway/src/pricing.ts) and 400s requests after the window;
// `availableUntil` below is the client-side guard — catalog resolution
// drops the entry at the same instant, so the picker never offers an
// id the gateway rejects. Keep the two dates in sync if the window
// moves.
{
id: 'opengateway-ling-3.0-tiny-free',
apiName: 'inclusionai/ling-3.0-tiny:free',
label: 'Ling 3.0 Tiny Free (via Opengateway)',
modelDescriptorId: 'inclusionai/ling-3.0-tiny:free',
notes: 'Free through August 13, 2026 (rate limited)',
availableUntil: '2026-08-13T10:00:00Z',
},
// Macaron — served by the gateway via direct Novita (not on
// OpenRouter). Paid since 2026-08-10.
{
+2
View File
@@ -19,6 +19,7 @@ import {
getAnthropicProxy,
getBrand,
getBrandsForVendor,
filterAvailableCatalogEntries,
getCatalogEntriesForRoute,
getCatalogForGateway,
getCatalogForVendor,
@@ -117,6 +118,7 @@ export {
getAllModels,
getCatalogForGateway,
getCatalogForVendor,
filterAvailableCatalogEntries,
getCatalogEntriesForRoute,
getModelsForBrand,
getModelsForGateway,
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, test } from 'bun:test'
import {
getCatalogEntriesForRoute,
getModel,
getModelsForBrand,
} from './index.js'
import { resolveModelRuntimeLimits } from './runtimeMetadata.js'
// All three sides of the Ling Tiny availability window (availableUntil on
// the gitlawb-opengateway catalog entry, mirroring the gateway's
// LING_TINY_FREE_END_ISO = 2026-08-13T10:00:00Z). The cutoff itself is
// exclusive: at exactly the cutoff instant the entry is already gone.
const DURING_WINDOW = new Date('2026-08-12T00:00:00Z')
const AT_CUTOFF = new Date('2026-08-13T10:00:00Z')
const AFTER_WINDOW = new Date('2026-08-13T10:00:01Z')
describe('Ling 3.0 Tiny :free descriptor', () => {
test('exposes the Tiny capabilities and limits to gateway catalogs', () => {
const model = getModel('inclusionai/ling-3.0-tiny:free')
expect(model).toBeDefined()
expect(model).toMatchObject({
id: 'inclusionai/ling-3.0-tiny:free',
brandId: 'ling',
classification: ['chat', 'reasoning', 'coding'],
contextWindow: 262_144,
maxOutputTokens: 32_768,
capabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: false,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
})
expect(getModelsForBrand('ling').map(m => m.id)).toContain(
'inclusionai/ling-3.0-tiny:free',
)
// The gateway entry must map BOTH the wire id (apiName) and the picker
// descriptor to the :free id — a mismatch would send a different model
// upstream than the picker advertises.
const catalogEntry = getCatalogEntriesForRoute(
'gitlawb-opengateway',
DURING_WINDOW,
).find(entry => entry.apiName === 'inclusionai/ling-3.0-tiny:free')
expect(catalogEntry?.id).toBe('opengateway-ling-3.0-tiny-free')
expect(catalogEntry?.modelDescriptorId).toBe(model?.id)
expect(
resolveModelRuntimeLimits({
model: 'inclusionai/ling-3.0-tiny:free',
baseUrl: 'https://opengateway.gitlawb.com/v1',
processEnv: {},
}),
).toEqual({ contextWindow: 262_144, maxOutputTokens: 32_768 })
})
test('availableUntil drops the entry from catalog resolution after the window', () => {
const during = getCatalogEntriesForRoute('gitlawb-opengateway', DURING_WINDOW)
expect(during.some(e => e.id === 'opengateway-ling-3.0-tiny-free')).toBe(true)
// Boundary: at exactly the cutoff instant — and any time after — the
// picker must not offer the id the gateway now rejects; the entry
// disappears without a client release.
const atCutoff = getCatalogEntriesForRoute('gitlawb-opengateway', AT_CUTOFF)
expect(atCutoff.some(e => e.id === 'opengateway-ling-3.0-tiny-free')).toBe(false)
const after = getCatalogEntriesForRoute('gitlawb-opengateway', AFTER_WINDOW)
expect(after.some(e => e.id === 'opengateway-ling-3.0-tiny-free')).toBe(false)
// Entries without availableUntil are untouched by the filter.
expect(after.some(e => e.id === 'opengateway-ling-3.0-flash-free')).toBe(true)
expect(after.length).toBe(during.length - 1)
})
})
+18
View File
@@ -19,4 +19,22 @@ export default [
contextWindow: 262_144,
maxOutputTokens: 32_768,
}),
defineModel({
id: 'inclusionai/ling-3.0-tiny:free',
label: 'Ling 3.0 Tiny (free)',
brandId: 'ling',
vendorId: 'openai',
classification: ['chat', 'reasoning', 'coding'],
defaultModel: 'inclusionai/ling-3.0-tiny:free',
capabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: false,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
contextWindow: 262_144,
maxOutputTokens: 32_768,
}),
]
+57
View File
@@ -237,6 +237,63 @@ describe('catalog helpers', () => {
expect(getModelsForBrand('claude')).toHaveLength(1)
expect(getModelsForBrand('claude')[0]!.id).toBe('m-claude')
})
test('hidden entries are dropped from catalog resolution', () => {
registerGateway(
makeGateway('gw-1', {
catalog: {
source: 'static',
models: [
{ id: 'visible', apiName: 'model-visible' },
{ id: 'invisible', apiName: 'model-hidden', hidden: true },
],
},
}),
)
const entries = getCatalogEntriesForRoute('gw-1')
expect(entries.map(e => e.id)).toEqual(['visible'])
})
test('availableUntil expires entries at the cutoff instant, exclusive', () => {
registerGateway(
makeGateway('gw-1', {
catalog: {
source: 'static',
models: [
{ id: 'evergreen', apiName: 'model-evergreen' },
{
id: 'time-boxed',
apiName: 'model-window',
availableUntil: '2026-08-13T10:00:00Z',
},
],
},
}),
)
const before = getCatalogEntriesForRoute('gw-1', new Date('2026-08-13T09:59:59Z'))
expect(before.map(e => e.id)).toEqual(['evergreen', 'time-boxed'])
const atCutoff = getCatalogEntriesForRoute('gw-1', new Date('2026-08-13T10:00:00Z'))
expect(atCutoff.map(e => e.id)).toEqual(['evergreen'])
const after = getCatalogEntriesForRoute('gw-1', new Date('2026-08-13T10:00:01Z'))
expect(after.map(e => e.id)).toEqual(['evergreen'])
})
test('a malformed availableUntil fails open (entry stays visible)', () => {
registerGateway(
makeGateway('gw-1', {
catalog: {
source: 'static',
models: [
{ id: 'typo', apiName: 'model-typo', availableUntil: 'not-a-date' },
],
},
}),
)
const entries = getCatalogEntriesForRoute('gw-1', new Date('2099-01-01T00:00:00Z'))
expect(entries.map(e => e.id)).toEqual(['typo'])
})
})
// ---------------------------------------------------------------------------
+35 -9
View File
@@ -163,17 +163,43 @@ export function getCatalogForVendor(vendorId: string): import('./descriptors.js'
return _vendors.get(vendorId)?.catalog
}
export function getCatalogEntriesForRoute(routeId: string): ModelCatalogEntry[] {
/**
* True when a catalog entry should currently be exposed: not `hidden`, and
* not past its `availableUntil` expiry. An unparseable `availableUntil`
* fails open so a typo can't silently drop a model.
*/
function catalogEntryAvailable(entry: ModelCatalogEntry, now: Date): boolean {
if (entry.hidden) return false
if (entry.availableUntil !== undefined) {
const cutoff = Date.parse(entry.availableUntil)
if (Number.isFinite(cutoff) && now.getTime() >= cutoff) return false
}
return true
}
/**
* Drop `hidden` and expired (`availableUntil`) entries. Every surface that
* turns catalog entries into something selectable — route resolution here,
* the /model picker's static path, and static+discovery merges — must pass
* through this filter, or an expired entry resurfaces on that surface.
*/
export function filterAvailableCatalogEntries(
entries: ModelCatalogEntry[],
now: Date = new Date(),
): ModelCatalogEntry[] {
return entries.filter(entry => catalogEntryAvailable(entry, now))
}
export function getCatalogEntriesForRoute(
routeId: string,
now: Date = new Date(),
): ModelCatalogEntry[] {
ensureLoaded()
const gateway = _gateways.get(routeId)
if (gateway?.catalog?.models) {
return gateway.catalog.models
}
const vendor = _vendors.get(routeId)
if (vendor?.catalog?.models) {
return vendor.catalog.models
}
return []
const models =
gateway?.catalog?.models ?? _vendors.get(routeId)?.catalog?.models
if (!models) return []
return filterAvailableCatalogEntries(models, now)
}
export function getModelsForBrand(brandId: string): ModelDescriptor[] {
+68
View File
@@ -14,6 +14,12 @@ import {
resolveRouteCredentialValue,
resolveRouteIdFromBaseUrl,
} from './routeMetadata.js'
import { ensureIntegrationsLoaded } from './index.js'
import { _clearRegistryForTesting, registerGateway } from './registry.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
test('isCloudflareBaseUrl matches Workers AI host but not the shared AI Gateway', () => {
// Workers AI lives on api.cloudflare.com.
@@ -941,3 +947,65 @@ test('resolveActiveRouteIdFromEnv does not infer Near AI with explicit provider
}),
).toBe('gemini')
})
test('getRouteDefaultModel skips hidden and expired catalog entries in the fallback', async () => {
// Self-contained registry mutation (same lock + clear + reload pattern as
// registry.test.ts) so the synthetic gateway never leaks into other tests.
await acquireSharedMutationLock('integrations/routeMetadata.test.ts')
try {
_clearRegistryForTesting()
registerGateway({
id: 'gw-default-fallback',
label: 'gw-default-fallback',
setup: { requiresAuth: true, authMode: 'api-key' },
transportConfig: { kind: 'openai-compatible' },
// No defaultModel on the descriptor → the catalog fallback path runs.
catalog: {
source: 'static',
models: [
// Marked default, but hidden — must be skipped.
{ id: 'hidden-default', apiName: 'model-hidden', default: true, hidden: true },
// availableUntil already past its cutoff — must be skipped.
{
id: 'expired',
apiName: 'model-expired',
availableUntil: '2020-01-01T00:00:00Z',
},
{ id: 'valid', apiName: 'model-valid' },
],
},
})
expect(getRouteDefaultModel('gw-default-fallback')).toBe('model-valid')
// Every entry filtered out → no implicit default at all, rather than an
// id the route would reject.
_clearRegistryForTesting()
registerGateway({
id: 'gw-default-fallback-empty',
label: 'gw-default-fallback-empty',
setup: { requiresAuth: true, authMode: 'api-key' },
transportConfig: { kind: 'openai-compatible' },
catalog: {
source: 'static',
models: [
{ id: 'hidden-only', apiName: 'model-hidden', hidden: true },
{
id: 'expired-only',
apiName: 'model-expired',
availableUntil: '2020-01-01T00:00:00Z',
},
],
},
})
expect(getRouteDefaultModel('gw-default-fallback-empty')).toBeUndefined()
} finally {
// Nested so the lock is released even if the registry restore throws
// (same shape as registry.test.ts's afterEach).
try {
_clearRegistryForTesting()
ensureIntegrationsLoaded()
} finally {
releaseSharedMutationLock()
}
}
})
+6 -1
View File
@@ -6,6 +6,7 @@ import type {
} from './descriptors.js'
import {
ensureIntegrationsLoaded,
filterAvailableCatalogEntries,
getAllAnthropicProxies,
getAllGateways,
getAllVendors,
@@ -159,7 +160,11 @@ export function getRouteDefaultModel(
return descriptor.defaultModel
}
const catalogModels = descriptor.catalog?.models ?? []
// Same availability filter as the picker/route resolution — the fallback
// default must never be a hidden or expired entry.
const catalogModels = filterAvailableCatalogEntries(
descriptor.catalog?.models ?? [],
)
const defaultEntry =
catalogModels.find(model => model.default) ?? catalogModels[0]
+60 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import { buildRouteCatalogModelOptions } from './routeCatalogOptions.js'
import { filterAvailableCatalogEntries } from '../../integrations/index.js'
import { buildRouteCatalogModelOptions, mergeRouteCatalogEntries } from './routeCatalogOptions.js'
describe('buildRouteCatalogModelOptions', () => {
test('marks the route default model as recommended without catalog metadata', () => {
@@ -53,3 +54,61 @@ describe('buildRouteCatalogModelOptions', () => {
expect(options[0]?.description).toBe('Free · Provider: Gitlawb Opengateway')
})
})
describe('mergeRouteCatalogEntries + availability filter', () => {
// The exact composition used by loadDescriptorDiscoveryContext
// (src/commands/model/model.tsx): merge the RAW static list first, filter
// after. An expired static entry must win the apiName dedup against a
// cached discovery duplicate so the post-merge filter removes both copies.
test('a cached duplicate of an expired static entry does not resurface after the cutoff', () => {
const rawStatic = [
{ id: 'evergreen', apiName: 'model-evergreen' },
{
id: 'time-boxed',
apiName: 'model-window',
availableUntil: '2026-08-13T10:00:00Z',
},
]
const cachedDiscovery = [
// Same apiName as the expired static entry, but no expiry marker —
// exactly what a discovery cache would hold.
{ id: 'discovered-window', apiName: 'model-window' },
{ id: 'discovered-extra', apiName: 'model-extra' },
]
const afterCutoff = new Date('2026-08-13T10:00:01Z')
const merged = filterAvailableCatalogEntries(
mergeRouteCatalogEntries(rawStatic, cachedDiscovery),
afterCutoff,
)
expect(merged.map(e => e.id)).toEqual(['evergreen', 'discovered-extra'])
// Pre-filtering the static side (the buggy order) lets the cached
// duplicate through — documents why the raw list feeds the merge.
const buggy = filterAvailableCatalogEntries(
mergeRouteCatalogEntries(
filterAvailableCatalogEntries(rawStatic, afterCutoff),
cachedDiscovery,
),
afterCutoff,
)
expect(buggy.map(e => e.id)).toContain('discovered-window')
})
test('inside the window the static entry masks the cached duplicate', () => {
const rawStatic = [
{
id: 'time-boxed',
apiName: 'model-window',
availableUntil: '2026-08-13T10:00:00Z',
},
]
const cachedDiscovery = [{ id: 'discovered-window', apiName: 'model-window' }]
const during = new Date('2026-08-12T00:00:00Z')
const merged = filterAvailableCatalogEntries(
mergeRouteCatalogEntries(rawStatic, cachedDiscovery),
during,
)
expect(merged.map(e => e.id)).toEqual(['time-boxed'])
})
})