Skip to content
Merged
113 changes: 87 additions & 26 deletions src/commands/model/model.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
}
})

Expand Down
22 changes: 18 additions & 4 deletions src/commands/model/model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/integrations/brands/ling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 8 additions & 0 deletions src/integrations/descriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/integrations/gateways/gitlawb-opengateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// Macaron — served by the gateway via direct Novita (not on
// OpenRouter). Paid since 2026-08-10.
{
Expand Down
2 changes: 2 additions & 0 deletions src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
getAnthropicProxy,
getBrand,
getBrandsForVendor,
filterAvailableCatalogEntries,
getCatalogEntriesForRoute,
getCatalogForGateway,
getCatalogForVendor,
Expand Down Expand Up @@ -117,6 +118,7 @@ export {
getAllModels,
getCatalogForGateway,
getCatalogForVendor,
filterAvailableCatalogEntries,
getCatalogEntriesForRoute,
getModelsForBrand,
getModelsForGateway,
Expand Down
77 changes: 77 additions & 0 deletions src/integrations/ling-tiny.test.ts
Original file line number Diff line number Diff line change
@@ -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')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
})
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 changes: 18 additions & 0 deletions src/integrations/models/ling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 57 additions & 0 deletions src/integrations/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
})
})

// ---------------------------------------------------------------------------
Expand Down
Loading