mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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
@@ -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) |
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user