Skip to content
Open
42 changes: 27 additions & 15 deletions src/components/ProviderManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -961,13 +961,13 @@ test('ProviderManager clears hidden Hicap auth fields when editing', async () =>
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider profile') &&
frame.includes('Step 1 of 6'),
frame.includes('Step 1 of 8'),
)

for (let step = 2; step <= 6; step++) {
for (let step = 2; step <= 8; step++) {
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes(`Step ${step} of 6`),
frame.includes(`Step ${step} of 8`),
)
}
mounted.stdin.write('\r')
Expand Down Expand Up @@ -1037,25 +1037,25 @@ test('ProviderManager skips advanced fields for legacy Kimi Code profiles', asyn
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider profile') &&
frame.includes('Provider name') &&
frame.includes('Step 1 of 4'),
frame.includes('Step 1 of 6'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Base URL') &&
frame.includes('Step 2 of 4'),
frame.includes('Step 2 of 6'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Default model') &&
frame.includes('Step 3 of 4'),
frame.includes('Step 3 of 6'),
)

mounted.stdin.write('\r')
const output = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('API key') &&
frame.includes('Step 4 of 4'),
frame.includes('Step 4 of 6'),
)

expect(output).not.toContain('API mode')
Expand Down Expand Up @@ -1798,49 +1798,61 @@ test('ProviderManager editing an active multi-model provider keeps app state on
mounted.getOutput,
frame =>
frame.includes('Edit provider profile') &&
frame.includes('Step 1 of 8'),
frame.includes('Step 1 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 2 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 3 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 2 of 8'),
frame => frame.includes('Step 4 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 3 of 8'),
frame => frame.includes('Step 5 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 4 of 8'),
frame => frame.includes('Step 6 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 5 of 8'),
frame => frame.includes('Step 7 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 6 of 8'),
frame => frame.includes('Step 8 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 7 of 8'),
frame => frame.includes('Step 9 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 8 of 8'),
frame => frame.includes('Step 10 of 10'),
)

mounted.stdin.write('\r')
Expand Down
46 changes: 45 additions & 1 deletion src/components/ProviderManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ type DraftField =
| 'authHeader'
| 'authHeaderValue'
| 'customHeaders'
| 'contextWindowSize'
| 'maxOutputTokens'

type ProviderDraft = Record<DraftField, string>

Expand Down Expand Up @@ -194,6 +196,20 @@ const FORM_STEPS: Array<{
helpText: 'Optional. Extra non-auth request headers for providers that support them.',
optional: true,
},
{
key: 'contextWindowSize',
label: 'Context window size',
placeholder: 'e.g. 128000',
helpText: 'Optional. Max tokens for context (input + output + history).',
optional: true,
},
{
key: 'maxOutputTokens',
label: 'Max output tokens',
placeholder: 'e.g. 4096',
helpText: 'Optional. Max tokens the model can generate in a single turn.',
optional: true,
},
]

const GITHUB_PROVIDER_ID = '__github_models__'
Expand All @@ -215,6 +231,8 @@ function toDraft(profile: ProviderProfile): ProviderDraft {
authHeader: profile.authHeader ?? '',
authHeaderValue: profile.authHeaderValue ?? '',
customHeaders: serializeProfileCustomHeaders(profile.customHeaders) ?? '',
contextWindowSize: profile.contextWindowSize ? String(profile.contextWindowSize) : '',
maxOutputTokens: profile.maxOutputTokens ? String(profile.maxOutputTokens) : '',
}
}

Expand Down Expand Up @@ -251,6 +269,8 @@ function presetToDraft(preset: ProviderPreset): ProviderDraft {
authHeader: '',
authHeaderValue: '',
customHeaders: '',
contextWindowSize: '',
maxOutputTokens: '',
}
}

Expand Down Expand Up @@ -282,7 +302,13 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string {
routeSupportsAuthHeaders(routeId) && profile.authHeader
? ` · ${profile.authHeader} auth`
: ''
return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${authInfo} · ${keyInfo}${activeSuffix}`
const contextInfo = profile.contextWindowSize
? ` · ${Math.round(profile.contextWindowSize / 1000)}k ctx`
: ''
const tokensInfo = profile.maxOutputTokens
? ` · ${profile.maxOutputTokens} out`
: ''
return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${authInfo}${contextInfo}${tokensInfo} · ${keyInfo}${activeSuffix}`
}

function getGithubCredentialSourceFromEnv(
Expand Down Expand Up @@ -1229,6 +1255,12 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
Object.keys(parsedCustomHeaders.headers).length > 0
? parsedCustomHeaders.headers
: undefined,
contextWindowSize: nextDraft.contextWindowSize
? parseInt(nextDraft.contextWindowSize, 10)
: undefined,
maxOutputTokens: nextDraft.maxOutputTokens
? parseInt(nextDraft.maxOutputTokens, 10)
: undefined,
}

const saved = profileId
Expand Down Expand Up @@ -1469,6 +1501,18 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return
}

if (
(currentStepKey === 'contextWindowSize' ||
currentStepKey === 'maxOutputTokens') &&
trimmed.length > 0
) {
const parsed = parseInt(trimmed, 10)
if (isNaN(parsed) || parsed <= 0) {
setErrorMessage(`${currentStep.label} must be a positive number.`)
return
}
}

const nextDraft = {
...draft,
[currentStepKey]: trimmed,
Expand Down
168 changes: 168 additions & 0 deletions src/services/api/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { isIP } from 'node:net'
import { logForDebugging } from '../../utils/debug.js'
import { isCodexAlias } from '../../utils/model/modelDescriptor.js'

// Reads an env-var-style string intended as a URL or path, rejecting both
// empty strings and the literal string "undefined" that Windows shells can
// write when a variable is unset-then-referenced without quotes (issue #336).
export function asEnvUrl(value: string | undefined): string | undefined {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
if (trimmed === 'undefined') {
return undefined
}
return trimmed
}

const warnedUndefinedEnvNames = new Set<string>()

export function asNamedEnvUrl(
value: string | undefined,
envName: string,
): string | undefined {
if (!value) return undefined

const trimmed = value.trim()
if (!trimmed) return undefined

if (trimmed === 'undefined') {
if (!warnedUndefinedEnvNames.has(envName)) {
warnedUndefinedEnvNames.add(envName)
logForDebugging(
`[provider-config] Environment variable ${envName} is the literal string "undefined"; ignoring it.`,
{ level: 'warn' },
)
}
return undefined
}

return trimmed
}

export function isCodexBaseUrl(baseUrl: string | undefined): boolean {
if (!baseUrl) return false
try {
const parsed = new URL(baseUrl)
return (
parsed.hostname === 'chatgpt.com' &&
parsed.pathname.replace(/\/+$/, '') === '/backend-api/codex'
)
} catch {
return false
}
}

export function shouldUseCodexTransport(
model: string,
baseUrl: string | undefined,
): boolean {
const explicitBaseUrl = asEnvUrl(baseUrl)
return isCodexBaseUrl(explicitBaseUrl) || (!explicitBaseUrl && isCodexAlias(model))
}

export function getGithubEndpointType(
baseUrl: string | undefined,
): 'copilot' | 'models' | 'custom' {
if (!baseUrl) return 'copilot'
try {
const hostname = new URL(baseUrl).hostname.toLowerCase()
if (hostname === 'api.githubcopilot.com') {
return 'copilot'
}
if (hostname === 'models.github.ai' || hostname.endsWith('.github.ai')) {
return 'models'
}
return 'custom'
} catch {
return 'copilot'
}
}

const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1'])

function isPrivateIpv4Address(hostname: string): boolean {
const octets = hostname.split('.').map(part => Number.parseInt(part, 10))
if (octets.length !== 4 || octets.some(octet => Number.isNaN(octet))) {
return false
}

return (
octets[0] === 10 ||
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
(octets[0] === 192 && octets[1] === 168)
)
}

function isPrivateIpv6Address(hostname: string): boolean {
const firstHextet = hostname.split(':', 1)[0]
if (!firstHextet) return false

const prefix = Number.parseInt(firstHextet, 16)
if (Number.isNaN(prefix)) return false

return (prefix & 0xfe00) === 0xfc00 || (prefix & 0xffc0) === 0xfe80
}

export function isLocalProviderUrl(baseUrl: string | undefined): boolean {
if (!baseUrl) return false
try {
let hostname = new URL(baseUrl).hostname.toLowerCase()

// Strip IPv6 brackets added by the URL parser (e.g. "[::1]" -> "::1")
if (hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = hostname.slice(1, -1)
}

// Strip RFC6874 IPv6 zone identifiers (e.g. "fe80::1%25en0" -> "fe80::1")
const zoneIdIndex = hostname.indexOf('%25')
if (zoneIdIndex !== -1) {
hostname = hostname.slice(0, zoneIdIndex)
}

if (LOCALHOST_HOSTNAMES.has(hostname) || hostname === '0.0.0.0') {
return true
}
if (hostname.endsWith('.local')) {
return true
}

const ipVersion = isIP(hostname)
if (ipVersion === 4) {
// Treat the full 127.0.0.0/8 loopback range as local
const firstOctet = Number.parseInt(hostname.split('.', 1)[0] ?? '', 10)
return firstOctet === 127 || isPrivateIpv4Address(hostname)
}
if (ipVersion === 6) {
return isPrivateIpv6Address(hostname)
}

return false
} catch {
return false
}
}

export function parseOpenAICompatibleApiFormat(
value: string | undefined,
): 'chat_completions' | 'responses' | undefined {
if (!value) return undefined
const normalized = value.trim().toLowerCase().replace(/[- ]+/g, '_')
if (
normalized === 'responses' ||
normalized === 'response' ||
normalized === 'responses_api'
) {
return 'responses'
}
if (
normalized === 'chat_completions' ||
normalized === 'chat_completion' ||
normalized === 'completions' ||
normalized === 'completion' ||
normalized === 'chat'
) {
return 'chat_completions'
}
return undefined
}
2 changes: 2 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ export type ProviderProfile = {
authScheme?: OpenAICompatibleAuthScheme
authHeaderValue?: string
customHeaders?: Record<string, string>
contextWindowSize?: number
maxOutputTokens?: number
}

export type GlobalConfig = {
Expand Down
Loading