Fix custom provider context discovery (#1620)

* Fix custom provider context discovery

Teach the custom OpenAI-compatible gateway to discover context windows from /v1/models metadata, including LiteLLM model_info context_length and max_input_tokens fields.

Use cached discovery metadata when resolving runtime context and output limits, with sync cache reads kept memoized and partitioned by endpoint, credential, and custom headers.

Add provider-profile maxContextLength env overrides and document LiteLLM context metadata plus the CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS fallback.

Cover startup discovery, runtime cache lookup, custom gateway parsing, profile overrides, and env custom-header cache partitioning with focused tests.

* Fix discovery smoke test isolation

Clear CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC during discovery service test setup so full-suite environment state cannot force startup discovery down the nonessential-traffic skip path.

Verified with:

- bun test ./src/integrations/discoveryService.test.ts

- CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 targeted startup discovery test

- bun run smoke

- bun run typecheck

* Partition custom discovery startup test cache

Use a test-only custom header in the startup custom route discovery test so it exercises network discovery even when the full suite has pre-seeded the no-header custom discovery cache key.

Verified with:

- bun test ./src/integrations/discoveryService.test.ts

- CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 targeted startup discovery test

- bun run smoke

- bun run typecheck

* Fix profile context override lifecycle

Add CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS to managed profile cleanup so switching profiles clears stale context-window overrides, including same-model OpenAI-compatible switches.

Preserve persisted context-window overrides when rebuilding OpenAI-compatible startup env after restart.

Verified with:

- bun test src/utils/providerProfile.test.ts src/utils/providerProfiles.test.ts

- bun run typecheck

- bun run smoke

* Detect profile context override drift

Include CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS in OpenAI-compatible active-profile env alignment so managed profiles with maxContextLength are re-applied when the override is missing or stale.

Verified with:

- bun test src/utils/providerProfiles.test.ts

- bun run typecheck

- bun run smoke
This commit is contained in:
JATMN
2026-06-14 20:37:11 +08:00
committed by GitHub
parent 716c1d47f6
commit de726c43e1
14 changed files with 822 additions and 12 deletions
+1
View File
@@ -325,6 +325,7 @@ The **OpenClaude VS Code extension** can store the key in Secret Storage and set
| `OPENAI_MODEL` | OpenAI-compatible only | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` |
| `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` |
| `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` |
| `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` | No | JSON map of OpenAI-compatible model names to context windows, such as `{"custom-model":1000000}`. Use this when a custom provider does not expose context metadata from `/v1/models`. |
| `OPENCODE_API_KEY` | OpenCode Zen / Go | Shared API key for OpenCode Zen (pay-as-you-go) and OpenCode Go (subscription); get yours from https://opencode.ai |
| `MIMO_API_KEY` | Xiaomi MiMo route | Xiaomi MiMo API key for `https://api.xiaomimimo.com/v1`; mirrored into the OpenAI-compatible auth env when the MiMo route is active |
| `CLAUDE_CODE_USE_GEMINI` | Gemini only | Set to `1` to enable the direct Gemini provider path |
+29 -1
View File
@@ -45,6 +45,8 @@ model_list:
litellm_params:
model: together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo
api_key: os.environ/TOGETHER_API_KEY
model_info:
context_length: 131072
```
### Run the proxy
@@ -128,9 +130,34 @@ openclaude
- `OPENAI_MODEL` must match the **LiteLLM model alias** defined in your config, not the upstream raw provider model name.
- If your proxy requires authentication, use the proxy key (or `master_key`) in `OPENAI_API_KEY`.
- LiteLLM's OpenAI-compatible endpoint accepts the same request format as OpenAI, so OpenClaude works without any code changes.
- LiteLLM's OpenAI-compatible endpoint accepts the same request format as OpenAI, so OpenClaude works without custom request shaping.
- OpenClaude discovers LiteLLM model context from `/v1/models` when LiteLLM exposes `context_length`, `context_window`, `max_model_len`, or `max_input_tokens`, including under `model_info`.
- You can switch between any provider configured in LiteLLM by simply changing the `OPENAI_MODEL` value — no need to reconfigure OpenClaude.
### Context window detection
For custom LiteLLM aliases, add context metadata to each model entry when the
upstream model supports a larger window than OpenClaude's fallback:
```yaml
model_list:
- model_name: long-context-model
litellm_params:
model: openai/gpt-4.1
api_key: os.environ/OPENAI_API_KEY
model_info:
context_length: 1000000
max_input_tokens: 1000000
```
After startup discovery, `/context` uses this value for context budgeting. If
your proxy does not expose context metadata from `/v1/models`, set an explicit
override before launching:
```bash
export CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS='{"long-context-model":1000000}'
```
## 5. Troubleshooting
| Issue | Likely Cause | Fix |
@@ -138,6 +165,7 @@ openclaude
| 404 or Model Not Found | Model alias doesn't exist in LiteLLM config | Verify the `model_name` in `litellm_config.yaml` matches `OPENAI_MODEL` |
| Connection Refused | LiteLLM proxy isn't running | Start the proxy with `litellm --config litellm_config.yaml --port 4000` |
| Auth Failed | Missing or wrong `master_key` | Set the correct key in `OPENAI_API_KEY` |
| `/context` shows 128K for a larger model | LiteLLM is not exposing context metadata for the alias, or startup discovery has not refreshed | Add `model_info.context_length` or `model_info.max_input_tokens` to the LiteLLM config, restart the proxy, then restart OpenClaude; use `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` as an explicit override if needed |
| Upstream provider error | The backend provider key is missing or invalid | Ensure the upstream API key (e.g., `OPENAI_API_KEY`) is set in your LiteLLM proxy process environment |
| Tools fail but chat works | The selected model has weak function/tool calling support | Switch to a model with strong tool support (e.g., GPT-4o, Claude Sonnet) |
+99
View File
@@ -30,6 +30,13 @@ type PersistedDiscoveryCache = {
}
let discoveryCacheLockPromise: Promise<void> | null = null
const SYNC_CACHE_STAT_INTERVAL_MS = 1_000
let syncDiscoveryCacheSnapshot: {
cachePath: string
mtimeMs: number | null
checkedAtMs: number
cache: PersistedDiscoveryCache
} | null = null
export async function withDiscoveryCacheLock<T>(
fn: () => Promise<T>,
@@ -169,6 +176,69 @@ async function loadDiscoveryCache(): Promise<PersistedDiscoveryCache> {
}
}
function loadDiscoveryCacheSync(): PersistedDiscoveryCache {
const fs = getFsImplementation()
const cachePath = getDiscoveryCachePath()
const now = Date.now()
if (
syncDiscoveryCacheSnapshot?.cachePath === cachePath &&
now - syncDiscoveryCacheSnapshot.checkedAtMs < SYNC_CACHE_STAT_INTERVAL_MS
) {
return syncDiscoveryCacheSnapshot.cache
}
try {
if (!fs.existsSync(cachePath)) {
const cache = getEmptyDiscoveryCache()
syncDiscoveryCacheSnapshot = {
cachePath,
mtimeMs: null,
checkedAtMs: now,
cache,
}
return cache
}
const mtimeMs = fs.statSync(cachePath).mtimeMs
if (
syncDiscoveryCacheSnapshot?.cachePath === cachePath &&
syncDiscoveryCacheSnapshot.mtimeMs === mtimeMs
) {
syncDiscoveryCacheSnapshot = {
...syncDiscoveryCacheSnapshot,
checkedAtMs: now,
}
return syncDiscoveryCacheSnapshot.cache
}
const content = fs.readFileSync(cachePath, { encoding: 'utf-8' })
const parsed = jsonParse(content) as {
version?: unknown
entries?: unknown
}
const cache = migrateDiscoveryCache(parsed) ?? getEmptyDiscoveryCache()
syncDiscoveryCacheSnapshot = {
cachePath,
mtimeMs,
checkedAtMs: now,
cache,
}
return cache
} catch (error) {
logForDebugging(`Failed to load discovery cache: ${errorMessage(error)}`)
const cache = getEmptyDiscoveryCache()
syncDiscoveryCacheSnapshot = {
cachePath,
mtimeMs: null,
checkedAtMs: now,
cache,
}
return cache
}
}
async function saveDiscoveryCache(
cache: PersistedDiscoveryCache,
): Promise<void> {
@@ -189,6 +259,7 @@ async function saveDiscoveryCache(
}
await fs.rename(tempPath, cachePath)
syncDiscoveryCacheSnapshot = null
} catch (error) {
logError(error)
try {
@@ -265,6 +336,34 @@ export async function getCachedModels(
return entry
}
export function getCachedModelsSync(
routeId: string,
ttlMs: number,
options?: {
includeStale?: boolean
},
): DiscoveryCacheEntry | null {
const cache = loadDiscoveryCacheSync()
const entry = cache.entries[routeId]
if (!entry) {
return null
}
if (options?.includeStale) {
return entry
}
if (entry.updatedAt === null) {
return null
}
if (Date.now() - entry.updatedAt > ttlMs) {
return null
}
return entry
}
export async function isCacheStale(
routeId: string,
ttlMs: number,
+107
View File
@@ -16,6 +16,7 @@ const originalEnv = {
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS,
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL,
@@ -55,6 +56,7 @@ function clearProviderEnv(): void {
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_API_KEY
delete process.env.OPENAI_MODEL
delete process.env.ANTHROPIC_CUSTOM_HEADERS
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_MISTRAL
@@ -62,6 +64,7 @@ function clearProviderEnv(): void {
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
}
beforeEach(async () => {
@@ -85,6 +88,7 @@ afterEach(() => {
restoreEnvValue('OPENAI_API_BASE')
restoreEnvValue('OPENAI_API_KEY')
restoreEnvValue('OPENAI_MODEL')
restoreEnvValue('ANTHROPIC_CUSTOM_HEADERS')
restoreEnvValue('CLAUDE_CODE_USE_OPENAI')
restoreEnvValue('CLAUDE_CODE_USE_GEMINI')
restoreEnvValue('CLAUDE_CODE_USE_MISTRAL')
@@ -438,6 +442,109 @@ describe('discoverModelsForRoute', () => {
expect(result?.source).toBe('network')
})
test('refreshStartupDiscoveryForActiveRoute discovers custom route with hybrid startup discovery', async () => {
const { refreshStartupDiscoveryForActiveRoute } =
await loadDiscoveryServiceModule()
const startupEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: 'http://localhost:4000/v1',
ANTHROPIC_CUSTOM_HEADERS: 'X-Test-Case: startup-context',
}
setMockFetch(mock((_input, init) => {
expect(init?.headers).toEqual({ 'X-Test-Case': 'startup-context' })
return Promise.resolve(
new Response(
JSON.stringify({
data: [
{
id: 'litellm-proxy',
model_info: { context_length: 1_000_000 },
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as unknown as typeof globalThis.fetch)
const result = await refreshStartupDiscoveryForActiveRoute({
processEnv: startupEnv,
})
expect(result?.routeId).toBe('custom')
expect(result?.source).toBe('network')
expect((result?.models ?? [])[0]?.contextWindow).toBe(1_000_000)
})
test('refreshStartupDiscoveryForActiveRoute partitions custom discovery by env custom headers', async () => {
const { getDiscoveryCacheKey, refreshStartupDiscoveryForActiveRoute } =
await loadDiscoveryServiceModule()
const { getCachedModels } = await import('./discoveryCache.js')
const startupEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: 'http://localhost:4000/v1',
ANTHROPIC_CUSTOM_HEADERS: 'X-Tenant: acme',
}
setMockFetch(mock((input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url
expect(url).toBe('http://localhost:4000/v1/models')
expect(init?.headers).toEqual({ 'X-Tenant': 'acme' })
return Promise.resolve(
new Response(
JSON.stringify({
data: [
{
id: 'tenant-model',
model_info: { context_length: 1_000_000 },
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
}) as unknown as typeof globalThis.fetch)
const result = await refreshStartupDiscoveryForActiveRoute({
processEnv: startupEnv,
})
const cached = await getCachedModels(
getDiscoveryCacheKey('custom', {
baseUrl: 'http://localhost:4000/v1',
headers: { 'X-Tenant': 'acme' },
}),
24 * 60 * 60 * 1000,
)
expect(result?.routeId).toBe('custom')
expect(cached?.models[0]?.contextWindow).toBe(1_000_000)
})
test('refreshStartupDiscoveryForActiveRoute still skips anthropic route', async () => {
const { refreshStartupDiscoveryForActiveRoute } =
await loadDiscoveryServiceModule()
const startupEnv: NodeJS.ProcessEnv = {
CLAUDE_CODE_USE_ANTHROPIC: '1',
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
}
const result = await refreshStartupDiscoveryForActiveRoute({
processEnv: startupEnv,
})
expect(result).toBeNull()
})
test('openai-compatible discovery applies mapModel to filter and shape raw entries', async () => {
const { discoverModelsForRoute } = await loadDiscoveryServiceModule()
+6 -3
View File
@@ -29,6 +29,7 @@ import {
probeAtomicChatReadiness,
probeOllamaGenerationReadiness,
} from '../utils/providerDiscovery.js'
import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js'
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'
export type RouteDiscoveryResult = {
@@ -72,7 +73,7 @@ function getCatalogEntries(
return getRouteCatalog(routeId)?.models ?? []
}
function getDiscoveryCacheTtlMs(
export function getDiscoveryCacheTtlMs(
routeId: string,
): number {
const ttl = getRouteCatalog(routeId)?.discoveryCacheTtl ?? 0
@@ -435,13 +436,15 @@ export async function refreshStartupDiscoveryForActiveRoute(
}) ??
resolveRouteIdFromBaseUrl(baseUrl)
if (!routeId || routeId === 'anthropic' || routeId === 'custom') {
if (!routeId || routeId === 'anthropic') {
return null
}
return refreshStartupDiscoveryForRoute(routeId, {
baseUrl,
headers: options?.headers,
headers:
options?.headers ??
parseCustomHeadersEnv(processEnv.ANTHROPIC_CUSTOM_HEADERS),
apiKey:
options?.apiKey ??
resolveRouteCredentialValue({
+186
View File
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { _clearRegistryForTesting, ensureIntegrationsLoaded, getCatalogForGateway } from '../index.js'
describe('custom gateway', () => {
beforeEach(() => {
_clearRegistryForTesting()
ensureIntegrationsLoaded()
})
afterEach(() => {
_clearRegistryForTesting()
})
test('discovers /v1/models and maps context_length to contextWindow', async () => {
const catalog = getCatalogForGateway('custom')
expect(catalog?.source).toBe('hybrid')
expect(catalog?.discovery?.kind).toBe('openai-compatible')
const mapModel = catalog?.discovery?.mapModel
expect(mapModel).toBeDefined()
const mapped = mapModel?.({
id: 'litellm-gpt-4o',
object: 'model',
created: 123,
owned_by: 'organization',
context_length: 200_000,
})
expect(mapped).toEqual({
id: 'litellm-gpt-4o',
apiName: 'litellm-gpt-4o',
label: 'litellm-gpt-4o',
contextWindow: 200_000,
})
})
test('falls back to context_window when context_length is absent', async () => {
const catalog = getCatalogForGateway('custom')
const mapModel = catalog?.discovery?.mapModel
const mapped = mapModel?.({
id: 'litellm-claude-opus',
context_window: 200_000,
})
expect(mapped).toEqual({
id: 'litellm-claude-opus',
apiName: 'litellm-claude-opus',
label: 'litellm-claude-opus',
contextWindow: 200_000,
})
})
test('falls back to max_model_len when other fields are absent', async () => {
const catalog = getCatalogForGateway('custom')
const mapModel = catalog?.discovery?.mapModel
const mapped = mapModel?.({
id: 'litellm-qwen-3',
max_model_len: 131_072,
})
expect(mapped).toEqual({
id: 'litellm-qwen-3',
apiName: 'litellm-qwen-3',
label: 'litellm-qwen-3',
contextWindow: 131_072,
})
})
test('falls back to top-level max_input_tokens when other fields are absent', async () => {
const catalog = getCatalogForGateway('custom')
const mapModel = catalog?.discovery?.mapModel
const mapped = mapModel?.({
id: 'litellm-gpt-5',
max_input_tokens: 1_000_000,
})
expect(mapped).toEqual({
id: 'litellm-gpt-5',
apiName: 'litellm-gpt-5',
label: 'litellm-gpt-5',
contextWindow: 1_000_000,
})
})
test('falls back to LiteLLM model_info context fields', async () => {
const catalog = getCatalogForGateway('custom')
const mapModel = catalog?.discovery?.mapModel
expect(
mapModel?.({
id: 'litellm-claude-opus',
model_info: {
context_length: 1_000_000,
},
}),
).toEqual({
id: 'litellm-claude-opus',
apiName: 'litellm-claude-opus',
label: 'litellm-claude-opus',
contextWindow: 1_000_000,
})
expect(
mapModel?.({
id: 'litellm-qwen',
model_info: {
max_input_tokens: 131_072,
},
}),
).toEqual({
id: 'litellm-qwen',
apiName: 'litellm-qwen',
label: 'litellm-qwen',
contextWindow: 131_072,
})
})
test('omits contextWindow when provider does not expose any size', async () => {
const catalog = getCatalogForGateway('custom')
const mapModel = catalog?.discovery?.mapModel
const mapped = mapModel?.({
id: 'litellm-unknown',
})
expect(mapped).toEqual({
id: 'litellm-unknown',
apiName: 'litellm-unknown',
label: 'litellm-unknown',
})
})
test('skips models without an id', async () => {
const catalog = getCatalogForGateway('custom')
const mapModel = catalog?.discovery?.mapModel
expect(mapModel?.({})).toBeNull()
expect(mapModel?.({ id: ' ' })).toBeNull()
expect(mapModel?.(null)).toBeNull()
expect(mapModel?.('bad entry')).toBeNull()
})
test('ignores non-positive or non-integer context values', async () => {
const catalog = getCatalogForGateway('custom')
const mapModel = catalog?.discovery?.mapModel
expect(
mapModel?.({
id: 'negative',
context_length: -1,
}),
).toEqual({ id: 'negative', apiName: 'negative', label: 'negative' })
expect(
mapModel?.({
id: 'zero',
context_window: 0,
}),
).toEqual({ id: 'zero', apiName: 'zero', label: 'zero' })
expect(
mapModel?.({
id: 'float',
max_model_len: 128_000.5,
}),
).toEqual({ id: 'float', apiName: 'float', label: 'float' })
expect(
mapModel?.({
id: 'infinite',
context_length: Infinity,
}),
).toEqual({ id: 'infinite', apiName: 'infinite', label: 'infinite' })
expect(
mapModel?.({
id: 'nan',
context_length: NaN,
}),
).toEqual({ id: 'nan', apiName: 'nan', label: 'nan' })
})
})
+56 -1
View File
@@ -1,5 +1,27 @@
import { defineGateway } from '../define.js'
function getContextWindow(value: unknown): number | undefined {
if (
typeof value === 'number' &&
Number.isFinite(value) &&
Number.isInteger(value) &&
value > 0
) {
return value
}
return undefined
}
function getModelInfo(raw: unknown): Record<string, unknown> | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return undefined
}
const modelInfo = (raw as { model_info?: unknown }).model_info
return modelInfo && typeof modelInfo === 'object' && !Array.isArray(modelInfo)
? (modelInfo as Record<string, unknown>)
: undefined
}
export default defineGateway({
id: 'custom',
label: 'Custom OpenAI-compatible',
@@ -29,7 +51,40 @@ export default defineGateway({
vendorId: 'openai',
},
catalog: {
source: 'static',
source: 'hybrid',
discovery: {
kind: 'openai-compatible',
mapModel(raw: unknown) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return null
}
const model = raw as Record<string, unknown>
const modelId = typeof model.id === 'string' ? model.id.trim() : ''
if (!modelId) {
return null
}
const modelInfo = getModelInfo(raw)
const contextWindow =
getContextWindow(model.context_length) ??
getContextWindow(model.context_window) ??
getContextWindow(model.max_model_len) ??
getContextWindow(model.max_input_tokens) ??
getContextWindow(modelInfo?.context_length) ??
getContextWindow(modelInfo?.context_window) ??
getContextWindow(modelInfo?.max_model_len) ??
getContextWindow(modelInfo?.max_input_tokens)
return {
id: modelId,
apiName: modelId,
label: modelId,
...(contextWindow !== undefined ? { contextWindow } : {}),
}
},
},
discoveryCacheTtl: '1d',
discoveryRefreshMode: 'startup',
allowManualRefresh: true,
models: [],
},
usage: { supported: false },
+72 -1
View File
@@ -1,5 +1,76 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, it, expect } from 'bun:test'
import { resolveOpenAIShimRuntimeContext } from '../integrations/runtimeMetadata'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock'
import {
resolveModelRuntimeLimits,
resolveOpenAIShimRuntimeContext,
} from '../integrations/runtimeMetadata'
import { setCachedModels } from './discoveryCache'
import { getDiscoveryCacheKey } from './discoveryService'
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
async function withTempConfigDir<T>(fn: () => Promise<T>): Promise<T> {
await acquireSharedMutationLock('integrations/runtimeMetadata.test.ts')
let tempDir: string | null = null
try {
tempDir = mkdtempSync(join(tmpdir(), 'openclaude-runtime-metadata-test-'))
process.env.CLAUDE_CONFIG_DIR = tempDir
return await fn()
} finally {
try {
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
}
} finally {
releaseSharedMutationLock()
}
}
}
describe('resolveModelRuntimeLimits', () => {
it('uses discovered custom route context windows from the discovery cache', async () => {
await withTempConfigDir(async () => {
const baseUrl = 'http://localhost:4000/v1'
await setCachedModels(
getDiscoveryCacheKey('custom', {
baseUrl,
}),
{
models: [
{
id: 'litellm-proxy',
apiName: 'litellm-proxy',
label: 'litellm-proxy',
contextWindow: 1_000_000,
},
],
},
)
expect(
resolveModelRuntimeLimits({
model: 'litellm-proxy',
processEnv: {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: baseUrl,
},
}).contextWindow,
).toBe(1_000_000)
})
})
})
describe('resolveOpenAIShimRuntimeContext - segment-boundary heuristic', () => {
describe('DeepSeek models', () => {
+49
View File
@@ -6,6 +6,11 @@ import {
getOpenAIContextWindowMatches,
getOpenAIMaxOutputTokenMatches,
} from '../utils/model/openaiContextWindows.js'
import { getCachedModelsSync } from './discoveryCache.js'
import {
getDiscoveryCacheKey,
getDiscoveryCacheTtlMs,
} from './discoveryService.js'
import { ensureIntegrationsLoaded } from './index.js'
import {
getAllModels,
@@ -14,10 +19,12 @@ import {
} from './registry.js'
import {
getRouteDescriptor,
resolveRouteCredentialValue,
resolveActiveRouteIdFromEnv,
resolveRouteIdFromBaseUrl,
type RouteDescriptor,
} from './routeMetadata.js'
import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js'
function normalizeModelApiName(
value: string | undefined,
@@ -329,6 +336,40 @@ function findCatalogEntryForApiName(
return getCatalogEntryForModel(routeId, modelApiName)
}
function findCachedCatalogEntryForApiName(
routeId: string | null,
modelApiName: string | undefined,
runtimeEnv: NodeJS.ProcessEnv,
): ModelCatalogEntry | null {
const normalizedModel = normalizeModelApiName(modelApiName)
if (!routeId || routeId === 'anthropic' || !normalizedModel) {
return null
}
const catalog = getRouteDescriptor(routeId)?.catalog
if (!catalog?.discovery) {
return null
}
const baseUrl = runtimeEnv.OPENAI_BASE_URL ?? runtimeEnv.OPENAI_API_BASE
const cacheKey = getDiscoveryCacheKey(routeId, {
baseUrl,
apiKey: resolveRouteCredentialValue({
routeId,
baseUrl,
processEnv: runtimeEnv,
}),
headers: parseCustomHeadersEnv(runtimeEnv.ANTHROPIC_CUSTOM_HEADERS),
})
const cached = getCachedModelsSync(cacheKey, getDiscoveryCacheTtlMs(routeId))
return (
cached?.models.find(entry =>
matchesCatalogEntryModel(routeId, entry, normalizedModel),
) ?? null
)
}
export function resolveModelRuntimeLimits(options: {
model: string
processEnv?: NodeJS.ProcessEnv
@@ -345,8 +386,14 @@ export function resolveModelRuntimeLimits(options: {
activeProfileProvider: options.activeProfileProvider,
})
const catalogEntry = findCatalogEntryForApiName(routeId, options.model)
const cachedCatalogEntry = findCachedCatalogEntryForApiName(
routeId,
options.model,
runtimeEnv,
)
const modelDescriptor =
getModelDescriptorForCatalogEntry(catalogEntry) ??
getModelDescriptorForCatalogEntry(cachedCatalogEntry) ??
findModelDescriptorForApiName(routeId, options.model)
const externalContextWindow = getOpenAIContextWindowMatches(
options.model,
@@ -361,11 +408,13 @@ export function resolveModelRuntimeLimits(options: {
contextWindow:
externalContextWindow.exact ??
catalogEntry?.contextWindow ??
cachedCatalogEntry?.contextWindow ??
externalContextWindow.prefix ??
modelDescriptor?.contextWindow,
maxOutputTokens:
externalMaxOutputTokens.exact ??
catalogEntry?.maxOutputTokens ??
cachedCatalogEntry?.maxOutputTokens ??
externalMaxOutputTokens.prefix ??
modelDescriptor?.maxOutputTokens,
}
+5
View File
@@ -224,6 +224,11 @@ export type ProviderProfile = {
authScheme?: OpenAICompatibleAuthScheme
authHeaderValue?: string
customHeaders?: Record<string, string>
/**
* Optional manual override for the provider/model context window in tokens.
* Applied to OpenAI-compatible providers when resolving runtime limits.
*/
maxContextLength?: number
}
export type GlobalConfig = {
+18
View File
@@ -1628,6 +1628,24 @@ test('startup env normalizes a semicolon-separated persisted openai model list',
assert.equal(env.OPENAI_BASE_URL, 'https://api.openai.com/v1')
})
test('startup env preserves persisted openai context-window override', async () => {
const override = JSON.stringify({ 'gpt-4o': 1_000_000 })
const env = await buildStartupEnvFromProfile({
persisted: profile('openai', {
OPENAI_API_KEY: 'sk-live',
OPENAI_MODEL: 'gpt-4o',
OPENAI_BASE_URL: 'https://api.openai.com/v1',
CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS: override,
}),
processEnv: {},
})
assert.equal(env.CLAUDE_CODE_USE_OPENAI, '1')
assert.equal(env.OPENAI_MODEL, 'gpt-4o')
assert.equal(env.OPENAI_BASE_URL, 'https://api.openai.com/v1')
assert.equal(env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS, override)
})
test('auto profile falls back to openai when no viable ollama model exists', () => {
assert.equal(selectAutoProfile(null), 'openai')
assert.equal(selectAutoProfile('qwen2.5-coder:7b'), 'ollama')
+25 -6
View File
@@ -71,6 +71,7 @@ const PROFILE_ENV_KEYS = [
'OPENAI_AUTH_SCHEME',
'OPENAI_AUTH_HEADER_VALUE',
'OPENAI_API_KEY',
'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS',
'CODEX_API_KEY',
'CODEX_CREDENTIAL_SOURCE',
'CHATGPT_ACCOUNT_ID',
@@ -192,6 +193,7 @@ export type ProfileEnv = {
NEARAI_API_KEY?: string
FIREWORKS_API_KEY?: string
OPENCODE_API_KEY?: string
CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS?: string
}
export type ProfileFile = {
@@ -678,6 +680,7 @@ export function buildOpenAIProfileEnv(options: {
authHeader?: string | null
authScheme?: 'bearer' | 'raw' | null
authHeaderValue?: string | null
maxContextLength?: number | null
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
@@ -711,23 +714,31 @@ export function buildOpenAIProfileEnv(options: {
apiFormat: processEnv.OPENAI_API_FORMAT,
})
const useShellOpenAIConfig = shellOpenAIRequest.transport !== 'codex_responses'
const normalizedModel =
normalizeProfileModel(
sanitizeProviderConfigValue(options.model, secretSource),
) ||
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
defaultModel
return {
OPENAI_BASE_URL:
sanitizeProviderConfigValue(options.baseUrl, secretSource) ||
(useShellOpenAIConfig ? shellOpenAIBaseUrl : undefined) ||
DEFAULT_OPENAI_BASE_URL,
OPENAI_MODEL:
normalizeProfileModel(
sanitizeProviderConfigValue(options.model, secretSource),
) ||
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
defaultModel,
OPENAI_MODEL: normalizedModel,
...(options.apiFormat ? { OPENAI_API_FORMAT: options.apiFormat } : {}),
...(options.authHeader ? { OPENAI_AUTH_HEADER: options.authHeader } : {}),
...(options.authScheme ? { OPENAI_AUTH_SCHEME: options.authScheme } : {}),
...(authHeaderValue ? { OPENAI_AUTH_HEADER_VALUE: authHeaderValue } : {}),
...(key ? { OPENAI_API_KEY: key } : {}),
...(options.maxContextLength
? {
CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS: JSON.stringify({
[normalizedModel]: options.maxContextLength,
}),
}
: {}),
}
}
@@ -1661,6 +1672,14 @@ export async function buildLaunchEnv(options: {
if (customHeaders) {
env.ANTHROPIC_CUSTOM_HEADERS = customHeaders
}
const contextWindows =
processEnv.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS ||
(usePersistedOpenAIConfig
? persistedEnv.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS
: undefined)
if (contextWindows) {
env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS = contextWindows
}
return buildCompatibilityProcessEnv({
processEnv,
+133
View File
@@ -63,6 +63,7 @@ const RESTORED_KEYS = [
'MIMO_API_KEY',
'ATLAS_CLOUD_API_KEY',
'HICAP_API_KEY',
'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS',
] as const
type MockConfigState = {
@@ -778,6 +779,82 @@ describe('applyProviderProfileToProcessEnv', () => {
expect(String(process.env.XAI_API_KEY)).toBe('xai-test-key')
expect(getFreshAPIProvider()).toBe('xai')
})
test('openai-compatible profile applies maxContextLength env override', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
applyProviderProfileToProcessEnv(
buildProfile({
provider: 'custom',
baseUrl: 'http://localhost:4000/v1',
model: 'gpt-4o',
maxContextLength: 200_000,
}),
)
expect(process.env.OPENAI_BASE_URL).toBe('http://localhost:4000/v1')
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
expect(process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS).toBe(
JSON.stringify({ 'gpt-4o': 200_000 }),
)
})
test('openai-compatible profile switch clears previous same-model context override', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
const { resolveModelRuntimeLimits } = await import(
'../integrations/runtimeMetadata.js'
)
applyProviderProfileToProcessEnv(
buildProfile({
provider: 'custom',
baseUrl: 'http://localhost:4000/v1',
model: 'gpt-4o',
maxContextLength: 1_000_000,
}),
)
expect(
resolveModelRuntimeLimits({
model: 'gpt-4o',
processEnv: process.env,
}).contextWindow,
).toBe(1_000_000)
applyProviderProfileToProcessEnv(
buildProfile({
provider: 'openai',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
}),
)
expect(process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS).toBeUndefined()
expect(
resolveModelRuntimeLimits({
model: 'gpt-4o',
processEnv: process.env,
}).contextWindow,
).not.toBe(1_000_000)
})
test('non-openai-compatible profile ignores maxContextLength override', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
applyProviderProfileToProcessEnv(
buildProfile({
provider: 'anthropic',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-6',
maxContextLength: 200_000,
}),
)
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
expect(process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS).toBeUndefined()
})
})
describe('getProviderProfiles', () => {
@@ -802,6 +879,34 @@ describe('getProviderProfiles', () => {
expect(profiles).toHaveLength(1)
expect(profiles[0]?.provider).toBe('moonshot')
})
test('sanitizes maxContextLength to positive finite integers', async () => {
const { getProviderProfiles } = await importFreshProviderProfileModules()
saveMockGlobalConfig(current => ({
...current,
providerProfiles: [
buildProfile({ id: 'valid', maxContextLength: 128_000 }),
buildProfile({ id: 'negative', maxContextLength: -1 }),
buildProfile({ id: 'float', maxContextLength: 128_000.5 }),
buildProfile({ id: 'zero', maxContextLength: 0 }),
buildProfile({ id: 'infinity', maxContextLength: Infinity }),
buildProfile({ id: 'string', maxContextLength: '128000' as unknown as number }),
buildProfile({ id: 'missing' }),
],
}))
const profiles = getProviderProfiles()
const byId = (id: string) => profiles.find(p => p.id === id)
expect(byId('valid')?.maxContextLength).toBe(128_000)
expect(byId('negative')?.maxContextLength).toBeUndefined()
expect(byId('float')?.maxContextLength).toBeUndefined()
expect(byId('zero')?.maxContextLength).toBeUndefined()
expect(byId('infinity')?.maxContextLength).toBeUndefined()
expect(byId('string')?.maxContextLength).toBeUndefined()
expect(byId('missing')?.maxContextLength).toBeUndefined()
})
})
describe('applyActiveProviderProfileFromConfig', () => {
@@ -1004,6 +1109,34 @@ describe('applyActiveProviderProfileFromConfig', () => {
expect(process.env.OPENAI_BASE_URL).toBe('http://192.168.33.108:11434/v1')
})
test('re-applies active profile when context-window override drifts', async () => {
const { applyActiveProviderProfileFromConfig, applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
const activeProfile = buildProfile({
id: 'saved_openai',
baseUrl: 'http://localhost:4000/v1',
model: 'gpt-4o',
maxContextLength: 1_000_000,
})
applyProviderProfileToProcessEnv(activeProfile)
// Simulate an upgraded or partially restored process where the profile
// marker and core OpenAI env survived, but this PR's new override did not.
delete process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS
const applied = applyActiveProviderProfileFromConfig({
providerProfiles: [activeProfile],
activeProviderProfileId: 'saved_openai',
} as any)
expect(applied?.id).toBe('saved_openai')
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
expect(process.env.OPENAI_BASE_URL).toBe('http://localhost:4000/v1')
expect(String(process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS)).toBe(
JSON.stringify({ 'gpt-4o': 1_000_000 }),
)
})
test('does not re-apply active profile when flags conflict with current provider', async () => {
const { applyActiveProviderProfileFromConfig, applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
+36
View File
@@ -64,6 +64,7 @@ export type ProviderProfileInput = {
authScheme?: ProviderProfile['authScheme']
authHeaderValue?: ProviderProfile['authHeaderValue']
customHeaders?: ProviderProfile['customHeaders']
maxContextLength?: ProviderProfile['maxContextLength']
}
export type ProviderPresetDefaults = Omit<ProviderProfileInput, 'provider'> & {
@@ -172,6 +173,14 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null {
return null
}
const maxContextLength =
typeof profile.maxContextLength === 'number' &&
Number.isFinite(profile.maxContextLength) &&
profile.maxContextLength > 0 &&
Number.isInteger(profile.maxContextLength)
? profile.maxContextLength
: undefined
const sanitized: ProviderProfile = {
id,
name,
@@ -193,6 +202,9 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null {
if (customHeaders) {
sanitized.customHeaders = customHeaders
}
if (maxContextLength !== undefined) {
sanitized.maxContextLength = maxContextLength
}
return sanitized
}
@@ -232,6 +244,7 @@ function toProfile(
authScheme: input.authScheme,
authHeaderValue: input.authHeaderValue,
customHeaders: input.customHeaders,
maxContextLength: input.maxContextLength,
})
}
@@ -517,6 +530,12 @@ function isProcessEnvAlignedWithProfile(
)
}
const expectedContextWindows = profile.maxContextLength
? JSON.stringify({
[getPrimaryModel(profile.model)]: profile.maxContextLength,
})
: undefined
return (
processEnv.CLAUDE_CODE_USE_OPENAI !== undefined &&
processEnv.CLAUDE_CODE_USE_GEMINI === undefined &&
@@ -531,6 +550,10 @@ function isProcessEnvAlignedWithProfile(
sameOptionalEnvValue(processEnv.OPENAI_AUTH_HEADER, profile.authHeader) &&
sameOptionalEnvValue(processEnv.OPENAI_AUTH_SCHEME, profile.authScheme) &&
sameOptionalEnvValue(processEnv.OPENAI_AUTH_HEADER_VALUE, profile.authHeaderValue) &&
sameOptionalEnvValue(
processEnv.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS,
expectedContextWindows,
) &&
(!includeApiKey ||
sameOptionalEnvValue(processEnv.OPENAI_API_KEY, profile.apiKey)) &&
(profile.baseUrl?.toLowerCase().includes('bankr')
@@ -709,6 +732,11 @@ export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void
if (route.gatewayId === 'nvidia-nim') {
openAIProfileEnv.NVIDIA_NIM = '1'
}
if (profile.maxContextLength) {
openAIProfileEnv.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS = JSON.stringify({
[primaryModel]: profile.maxContextLength,
})
}
profileEnv = openAIProfileEnv
}
@@ -941,6 +969,7 @@ function buildOpenAICompatibleStartupEnv(
authHeader: activeProfile.authHeader,
authScheme: activeProfile.authScheme,
authHeaderValue: activeProfile.authHeaderValue,
maxContextLength: activeProfile.maxContextLength,
processEnv: {},
})
if (strictEnv) {
@@ -967,6 +996,13 @@ function buildOpenAICompatibleStartupEnv(
...(activeProfile.authHeader ? { OPENAI_AUTH_HEADER: activeProfile.authHeader } : {}),
...(activeProfile.authScheme ? { OPENAI_AUTH_SCHEME: activeProfile.authScheme } : {}),
...(activeProfile.authHeaderValue ? { OPENAI_AUTH_HEADER_VALUE: activeProfile.authHeaderValue } : {}),
...(activeProfile.maxContextLength
? {
CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS: JSON.stringify({
[getPrimaryModel(activeProfile.model)]: activeProfile.maxContextLength,
}),
}
: {}),
}
if (activeProfile.apiKey) {