mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
co-authored by
OpenClaude
parent
16e332e108
commit
7b03ad19a4
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getAnthropicProxy,
|
||||
getBrand,
|
||||
getBrandsForVendor,
|
||||
filterAvailableCatalogEntries,
|
||||
getCatalogEntriesForRoute,
|
||||
getCatalogForGateway,
|
||||
getCatalogForVendor,
|
||||
@@ -117,6 +118,7 @@ export {
|
||||
getAllModels,
|
||||
getCatalogForGateway,
|
||||
getCatalogForVendor,
|
||||
filterAvailableCatalogEntries,
|
||||
getCatalogEntriesForRoute,
|
||||
getModelsForBrand,
|
||||
getModelsForGateway,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,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]
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user