From f354329df3b9b8802ef2911bfdfe9fcedf782e8e Mon Sep 17 00:00:00 2001 From: xjdr-noumena Date: Wed, 1 Jul 2026 02:25:09 +0000 Subject: [PATCH] feat(auth): support OpenAI-compatible BYOK --- OSS_BUILD.md | 5 +- README.md | 13 ++- src/auth/capabilities/remote.ts | 24 ++++-- src/auth/runtime/AuthRuntime.test.ts | 84 ++++++++++++++++++- src/auth/runtime/AuthRuntime.ts | 22 +++-- src/auth/runtime/headers.ts | 4 +- src/capabilities/runtime.ts | 32 ++++--- .../installGitHubAppSession.test.ts | 2 + src/services/api/client.authE2E.test.ts | 8 +- src/services/api/client.ts | 6 +- src/services/api/errors.auth.test.ts | 32 ++++++- src/services/api/errors.ts | 16 ++-- src/services/api/inferenceClient.test.ts | 68 +++++++++++++++ src/services/api/inferenceClient.ts | 25 ++++++ .../api/openAICompatInferenceClient.test.ts | 70 ++++++++++++++++ .../api/openAICompatInferenceClient.ts | 48 ++++++++--- .../compact/autoCompact.ncodeModels.test.ts | 28 ++++++- src/utils/auth.ts | 22 +++-- src/utils/authEnv.test.ts | 27 +++++- src/utils/authEnv.ts | 66 ++++++++++++++- src/utils/context.1m-tier-contract.test.ts | 17 ++-- src/utils/http.ts | 1 + src/utils/managedEnv.ts | 60 +++++++++++-- src/utils/managedEnvConstants.test.ts | 9 ++ src/utils/managedEnvConstants.ts | 7 +- src/utils/model/model.auth.test.ts | 46 +++++++++- src/utils/model/model.ts | 26 +++++- src/utils/model/modelOptions.auth.test.ts | 50 +++++++++++ src/utils/model/modelOptions.ts | 29 ++++++- src/utils/model/providers.test.ts | 28 +++++++ src/utils/model/providers.ts | 35 ++++++-- src/utils/preflightChecks.test.ts | 7 +- src/utils/statusNoticeDefinitions.tsx | 7 +- src/utils/subprocessEnv.ts | 1 + 34 files changed, 833 insertions(+), 92 deletions(-) diff --git a/OSS_BUILD.md b/OSS_BUILD.md index 4f938e2..ba2d365 100644 --- a/OSS_BUILD.md +++ b/OSS_BUILD.md @@ -94,13 +94,16 @@ The canonical login path for Noumena-managed accounts is OAuth: You can also run the app and type `/login` in the REPL. Complete the browser OAuth flow to connect the local CLI to your Noumena-managed account. -Noumena API keys and BYOK remain supported for automation and direct-provider workflows: +Noumena API keys and BYOK remain supported for automation and direct-provider workflows. Noumena OAuth login is optional and independent from inference provider selection: ```bash NOUMENA_API_KEY=... .tmp/packages/ncode-0.1.0-linux-x64/ncode ANTHROPIC_API_KEY=... .tmp/packages/ncode-0.1.0-linux-x64/ncode +OPENAI_API_KEY=... OPENAI_BASE_URL=https://openrouter.ai/api/v1 OPENAI_MODEL=inclusionai/ling-2.6-flash .tmp/packages/ncode-0.1.0-linux-x64/ncode ``` +Direct key precedence is `NOUMENA_API_KEY`, then `ANTHROPIC_API_KEY`, then `OPENAI_API_KEY`. Project settings cannot silently set OpenAI BYOK routing variables. + ## Run locally Run the native binary produced by `bun run build`: diff --git a/README.md b/README.md index 0eb6cf6..25b9118 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,24 @@ The canonical way to use Noumena-managed accounts is OAuth: Complete the browser OAuth flow. You can also start the app and run `/login` from inside the REPL. -Noumena API keys and BYOK are supported alternatives for automation and direct-provider workflows: +Noumena API keys and BYOK are supported alternatives for automation and direct-provider workflows. Noumena OAuth login is optional and can coexist with any of these inference providers. ```bash +# Noumena-managed API key NOUMENA_API_KEY=... .tmp/packages/ncode-0.1.0-linux-x64/ncode + +# Native Anthropic BYOK ANTHROPIC_API_KEY=... .tmp/packages/ncode-0.1.0-linux-x64/ncode + +# OpenAI-compatible BYOK, including OpenRouter or self-hosted gateways +OPENAI_API_KEY=... \ +OPENAI_BASE_URL=https://openrouter.ai/api/v1 \ +OPENAI_MODEL=inclusionai/ling-2.6-flash \ +.tmp/packages/ncode-0.1.0-linux-x64/ncode ``` +Direct key precedence is `NOUMENA_API_KEY`, then `ANTHROPIC_API_KEY`, then `OPENAI_API_KEY`. `OPENAI_BASE_URL` and `OPENAI_MODEL` are only used when `OPENAI_API_KEY` is the active direct key. Project-checked-in settings cannot silently set OpenAI BYOK routing variables; keep provider keys in your shell, user settings, or a secret manager. + The launcher also reads `~/.config/noumena/ncode/api_key` by default. Service endpoints can be overridden for non-default infrastructure: ```bash diff --git a/src/auth/capabilities/remote.ts b/src/auth/capabilities/remote.ts index 2552134..b997fdd 100644 --- a/src/auth/capabilities/remote.ts +++ b/src/auth/capabilities/remote.ts @@ -150,9 +150,13 @@ export function persistManagedRemoteBootstrapFailure( export async function resolveManagedRemoteCapability( options: ResolveManagedRemoteCapabilityOptions = {}, ): Promise { - const session = await getAuthRuntime().resolveSession({ + const activeSession = await getAuthRuntime().resolveSession({ allowRefresh: true, }) + const session = + activeSession.principalSource === 'managed_oauth' + ? activeSession + : (getAuthRuntime().getCurrentManagedSession() ?? activeSession) const accessTokenOverride = normalizeAccessTokenOverride( options.accessTokenOverride, ) @@ -191,7 +195,11 @@ export async function resolveManagedRemoteCapability( export async function resolveManagedRemoteBootstrapCapability( options: ResolveManagedRemoteCapabilityOptions = {}, ): Promise { - const initialSession = getAuthRuntime().getCurrentSession() + const activeSession = getAuthRuntime().getCurrentSession() + const initialSession = + activeSession.principalSource === 'managed_oauth' + ? activeSession + : (getAuthRuntime().getCurrentManagedSession() ?? activeSession) const accessTokenOverride = normalizeAccessTokenOverride( options.accessTokenOverride, ) @@ -235,6 +243,7 @@ export async function resolveManagedRemoteRuntimeAuth( const runtimeAuth: ManagedRemoteRuntimeAuth = { getAccessToken: () => accessTokenOverride ?? + getAuthRuntime().getCurrentManagedSession()?.accessToken ?? getAuthRuntime().getCurrentSession().accessToken ?? undefined, onAuth401: staleAccessToken => @@ -288,16 +297,21 @@ export async function refreshManagedRemoteRuntimeAccessToken( export async function resolveManagedRemoteRuntimeLease( options: ResolveManagedRemoteCapabilityOptions = {}, ): Promise { + const activeProviderSession = getAuthRuntime().getCurrentSession() const capability = await resolveManagedRemoteBootstrapCapability(options) + const leaseSession = + activeProviderSession.providerPlan.mode === 'byok_static_env' + ? activeProviderSession + : capability.session return { accessToken: capability.accessToken, orgUUID: capability.orgUUID, - session: capability.session, + session: leaseSession, lease: buildRemoteSessionLease({ organizationUuid: capability.orgUUID, - providerMode: getRemoteRuntimeProviderMode(capability.session), - session: capability.session, + providerMode: getRemoteRuntimeProviderMode(leaseSession), + session: leaseSession, }), } } diff --git a/src/auth/runtime/AuthRuntime.test.ts b/src/auth/runtime/AuthRuntime.test.ts index c1a0e46..8491bbe 100644 --- a/src/auth/runtime/AuthRuntime.test.ts +++ b/src/auth/runtime/AuthRuntime.test.ts @@ -21,6 +21,9 @@ const envKeys = [ 'CLAUDE_CONFIG_DIR', 'NOUMENA_API_KEY', 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', + 'OPENAI_BASE_URL', + 'OPENAI_MODEL', 'ANTHROPIC_AUTH_TOKEN', 'NCODE_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', @@ -75,6 +78,9 @@ function setStableTestRuntime(): void { process.env.USER_TYPE = 'test' delete process.env.NOUMENA_API_KEY delete process.env.ANTHROPIC_API_KEY + delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_MODEL delete process.env.ANTHROPIC_AUTH_TOKEN delete process.env.NCODE_OAUTH_TOKEN delete process.env.CLAUDE_CODE_OAUTH_TOKEN @@ -176,7 +182,7 @@ describe('AuthRuntime', () => { ) }) - it('fails closed for expired managed auth even if an API key fallback exists', async () => { + it('uses direct API-key inference auth even when stored managed auth is expired', async () => { process.env.NOUMENA_API_KEY = 'fallback-api-key' saveOAuthTokensIfNeeded({ accessToken: 'expired-access-token', @@ -188,9 +194,22 @@ describe('AuthRuntime', () => { }) clearOAuthTokenCache() - await expect(getAuthRuntime().buildFirstPartyHeaders()).rejects.toThrow( - 'Managed OAuth authentication expired and could not be refreshed.', - ) + const session = getAuthRuntime().getCurrentSession() + const managedSession = getAuthRuntime().getCurrentManagedSession() + const headers = await getAuthRuntime().buildFirstPartyHeaders() + + expect(session).toMatchObject({ + principalSource: 'direct_api_key_env', + sessionState: 'usable', + rawApiKeySource: 'NOUMENA_API_KEY', + }) + expect(managedSession).toMatchObject({ + principalSource: 'managed_oauth', + sessionState: 'expired', + }) + expect(headers).toEqual({ + 'x-api-key': 'fallback-api-key', + }) }) it('uses NOUMENA_API_KEY when the static API-key transport is explicit', async () => { @@ -395,6 +414,32 @@ describe('AuthRuntime', () => { }) }) + it('keeps OPENAI_API_KEY as OpenAI-compatible BYOK without first-party headers', async () => { + delete process.env.NOUMENA_API_KEY + delete process.env.ANTHROPIC_API_KEY + process.env.OPENAI_API_KEY = 'openai-direct-key' + + const session = getAuthRuntime().getCurrentSession() + const headers = await getAuthRuntime().buildFirstPartyHeaders() + + expect(session).toMatchObject({ + principalKind: 'api_key_user', + principalSource: 'direct_api_key_env', + sessionState: 'usable', + headersKind: 'none', + providerAuthKind: 'byok_static_env', + providerPlan: { + mode: 'byok_static_env', + source: 'direct_api_key_env', + staticKeyEnvVarName: 'OPENAI_API_KEY', + }, + rawApiKeySource: 'OPENAI_API_KEY', + hasUsableApiKey: true, + apiKey: 'openai-direct-key', + }) + expect(headers).toEqual({}) + }) + it('warms apiKeyHelper-backed sessions before status and header resolution', async () => { const helperPath = await createApiKeyHelperScript('helper-api-key') setFlagSettingsInline({ apiKeyHelper: helperPath }) @@ -468,6 +513,37 @@ describe('AuthRuntime', () => { ) }) + it('surfaces the stored managed session separately from OpenAI BYOK inference auth', () => { + process.env.OPENAI_API_KEY = 'openai-direct-key' + getSecureStorage().update({ + claudeAiOauth: { + accessToken: 'managed-access-token', + refreshToken: 'managed-refresh-token', + expiresAt: Date.now() + 60 * 60 * 1000, + scopes: ['user:profile'], + subscriptionType: 'pro', + rateLimitTier: 'tier-1', + }, + }) + clearOAuthTokenCache() + + const session = getAuthRuntime().getCurrentSession() + const managedSession = getAuthRuntime().getCurrentManagedSession() + + expect(session).toMatchObject({ + principalSource: 'direct_api_key_env', + headersKind: 'none', + providerAuthKind: 'byok_static_env', + rawApiKeySource: 'OPENAI_API_KEY', + }) + expect(managedSession).toMatchObject({ + principalSource: 'managed_oauth', + sessionState: 'usable', + accessToken: 'managed-access-token', + scopes: ['user:profile'], + }) + }) + it('prefers static BYOK env-key auth over injected remote OAuth when the remote lease is BYOK', async () => { process.env.NCODE_OAUTH_TOKEN = 'remote-managed-token' process.env.NCODE_REMOTE_RUNTIME_PROVIDER_MODE = 'byok' diff --git a/src/auth/runtime/AuthRuntime.ts b/src/auth/runtime/AuthRuntime.ts index f6f9ab3..83cf740 100644 --- a/src/auth/runtime/AuthRuntime.ts +++ b/src/auth/runtime/AuthRuntime.ts @@ -12,9 +12,11 @@ import { clearOAuthTokenCache, } from '../../utils/auth.js' import { - getDirectApiKeyEnvValue, getDirectApiKeyProviderMode, getDirectApiKeyEnvVarName, + getDirectApiKeyEnvValue, + isDirectApiKeyEnvVarName, + isOpenAIDirectApiKeySource, } from '../../utils/authEnv.js' import { getIsNonInteractiveSession } from '../../bootstrap/state.js' import { getGlobalConfig } from '../../utils/config.js' @@ -132,7 +134,7 @@ function mapPrincipalSource(params: { if ( process.env.NCODE_REMOTE_RUNTIME_TOKEN_TRANSPORT === 'static_api_key_env' && - (apiKeySource === 'NOUMENA_API_KEY' || apiKeySource === 'ANTHROPIC_API_KEY') + isDirectApiKeyEnvVarName(apiKeySource) ) { return 'direct_api_key_env' } @@ -143,6 +145,12 @@ function mapPrincipalSource(params: { ) { return 'direct_api_key_env' } + if ( + process.env.NCODE_REMOTE_RUNTIME_PROVIDER_MODE === 'byok_openai' && + apiKeySource === 'OPENAI_API_KEY' + ) { + return 'direct_api_key_env' + } if (authTokenSource === 'ANTHROPIC_AUTH_TOKEN') { return 'external_bearer_compat' @@ -160,6 +168,9 @@ function mapPrincipalSource(params: { ) { return 'service_oauth_fd' } + if (isDirectApiKeyEnvVarName(apiKeySource)) { + return 'direct_api_key_env' + } if (hasStoredManagedPrincipal || authTokenSource === 'noumena.com') { return 'managed_oauth' } @@ -169,9 +180,6 @@ function mapPrincipalSource(params: { if (apiKeySource === 'apiKeyHelper') { return 'api_key_helper' } - if (apiKeySource === 'NOUMENA_API_KEY' || apiKeySource === 'ANTHROPIC_API_KEY') { - return 'direct_api_key_env' - } return 'none' } @@ -474,7 +482,9 @@ function resolveSessionSnapshot(): ResolvedAuthSession { } else if (principalSource === 'direct_api_key_env') { principalKind = 'api_key_user' sessionState = hasUsableApiKey ? 'usable' : 'invalid' - headersKind = 'api_key' + headersKind = isOpenAIDirectApiKeySource(rawApiKeySource) + ? 'none' + : 'api_key' } else if (principalSource !== 'none') { principalKind = 'service_principal' sessionState = hasUsableToken ? 'usable' : 'invalid' diff --git a/src/auth/runtime/headers.ts b/src/auth/runtime/headers.ts index b5fe3cf..0a3ff7b 100644 --- a/src/auth/runtime/headers.ts +++ b/src/auth/runtime/headers.ts @@ -33,7 +33,9 @@ export function buildFirstPartyHeadersFromSession({ } if (includeApiKeyHeader) { - const resolvedApiKey = apiKey ?? session.apiKey + const resolvedApiKey = + apiKey ?? + (session.rawApiKeySource === 'OPENAI_API_KEY' ? undefined : session.apiKey) if (resolvedApiKey) { headers['x-api-key'] = resolvedApiKey } diff --git a/src/capabilities/runtime.ts b/src/capabilities/runtime.ts index ee0b0e3..a126d0a 100644 --- a/src/capabilities/runtime.ts +++ b/src/capabilities/runtime.ts @@ -11,6 +11,11 @@ import { getAuthRuntime } from '../auth/runtime/AuthRuntime.js' import type { AuthProvider, AccessMode } from './types.js' +import { + getDirectApiKeyProviderKind, + isAnthropicDirectApiKeySource, + isOpenAIDirectApiKeySource, +} from '../utils/authEnv.js' /** * Determine the active auth provider from the current auth session. @@ -29,21 +34,25 @@ export function getAuthProvider(): AuthProvider { if ( source === 'console_api_key' || source === 'api_key_helper' || - (source === 'direct_api_key_env' && session.rawApiKeySource !== 'ANTHROPIC_API_KEY') + (source === 'direct_api_key_env' && + !isAnthropicDirectApiKeySource(session.rawApiKeySource) && + !isOpenAIDirectApiKeySource(session.rawApiKeySource)) ) { return 'noumena-apikey' } if ( source === 'third_party_provider' || - source === 'external_bearer_compat' + source === 'external_bearer_compat' || + (source === 'direct_api_key_env' && + isOpenAIDirectApiKeySource(session.rawApiKeySource)) ) { return 'byok-openai' } if ( source === 'direct_api_key_env' && - session.rawApiKeySource === 'ANTHROPIC_API_KEY' + isAnthropicDirectApiKeySource(session.rawApiKeySource) ) { return 'byok-anthropic' } @@ -51,15 +60,14 @@ export function getAuthProvider(): AuthProvider { // Auth runtime not initialized yet — fall through to env-based heuristics } - // Fallback: detect from environment when auth runtime isn't ready - if (process.env.ANTHROPIC_API_KEY) { - return 'byok-anthropic' - } - if (process.env.OPENAI_API_KEY) { - return 'byok-openai' - } - if (process.env.NOUMENA_API_KEY) { - return 'noumena-apikey' + // Fallback: detect from environment using the same precedence as authEnv. + switch (getDirectApiKeyProviderKind()) { + case 'noumena': + return 'noumena-apikey' + case 'anthropic': + return 'byok-anthropic' + case 'openai_compat': + return 'byok-openai' } // Default: assume noumena-managed if nothing else is set and we're not public diff --git a/src/commands/install-github-app/installGitHubAppSession.test.ts b/src/commands/install-github-app/installGitHubAppSession.test.ts index bf5e75b..a6f70b0 100644 --- a/src/commands/install-github-app/installGitHubAppSession.test.ts +++ b/src/commands/install-github-app/installGitHubAppSession.test.ts @@ -18,6 +18,7 @@ const envKeys = [ 'CLAUDE_CONFIG_DIR', 'NOUMENA_API_KEY', 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', @@ -60,6 +61,7 @@ function setStableTestRuntime(): void { process.env.USER_TYPE = 'test' delete process.env.NOUMENA_API_KEY delete process.env.ANTHROPIC_API_KEY + delete process.env.OPENAI_API_KEY delete process.env.CLAUDE_CODE_USE_BEDROCK delete process.env.CLAUDE_CODE_USE_VERTEX delete process.env.CLAUDE_CODE_USE_FOUNDRY diff --git a/src/services/api/client.authE2E.test.ts b/src/services/api/client.authE2E.test.ts index 0cc5658..178c92c 100644 --- a/src/services/api/client.authE2E.test.ts +++ b/src/services/api/client.authE2E.test.ts @@ -226,7 +226,7 @@ describe('getFirstPartyRequestHeaders terminal re-auth end-to-end', () => { }) }) - it('fails closed instead of falling back to a stale bearer or API key when managed re-auth cannot recover', async () => { + it('uses direct API-key auth when managed re-auth cannot recover but a direct key is configured', async () => { process.env.NOUMENA_API_KEY = 'fallback-api-key' saveOAuthTokensIfNeeded({ accessToken: 'expired-access-token', @@ -247,8 +247,8 @@ describe('getFirstPartyRequestHeaders terminal re-auth end-to-end', () => { }, }) - await expect(getFirstPartyRequestHeaders()).rejects.toThrow( - 'Managed OAuth authentication expired and could not be refreshed.', - ) + await expect(getFirstPartyRequestHeaders()).resolves.toMatchObject({ + 'x-api-key': 'fallback-api-key', + }) }) }) diff --git a/src/services/api/client.ts b/src/services/api/client.ts index bd94a22..bde62d9 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -263,6 +263,10 @@ export async function getAnthropicClient({ // Determine authentication method based on available tokens const legacyAnthropicSdkBaseUrl = getLegacyAnthropicSdkBaseUrl() const currentSession = getAuthRuntime().getCurrentSession() + const currentSessionApiKey = + currentSession.rawApiKeySource === 'OPENAI_API_KEY' + ? null + : currentSession.apiKey const shouldUseCanonicalAuthToken = currentSession.sessionState === 'usable' && Boolean(currentSession.accessToken) && @@ -272,7 +276,7 @@ export async function getAnthropicClient({ const clientConfig: ConstructorParameters[0] = { apiKey: shouldUseCanonicalAuthToken ? null - : apiKey || currentSession.apiKey, + : apiKey || currentSessionApiKey, authToken: shouldUseCanonicalAuthToken ? currentSession.accessToken ?? undefined : undefined, diff --git a/src/services/api/errors.auth.test.ts b/src/services/api/errors.auth.test.ts index b98c3e3..6e0c243 100644 --- a/src/services/api/errors.auth.test.ts +++ b/src/services/api/errors.auth.test.ts @@ -24,6 +24,7 @@ const envKeys = [ 'CLAUDE_CODE_ENTRYPOINT', 'NOUMENA_API_KEY', 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_SESSION_ACCESS_TOKEN', @@ -63,6 +64,7 @@ function setStableTestRuntime() { delete process.env.CI delete process.env.NOUMENA_API_KEY delete process.env.ANTHROPIC_API_KEY + delete process.env.OPENAI_API_KEY delete process.env.ANTHROPIC_AUTH_TOKEN delete process.env.CLAUDE_CODE_OAUTH_TOKEN delete process.env.CLAUDE_CODE_SESSION_ACCESS_TOKEN @@ -135,14 +137,15 @@ function makeManagedErrorSession(): ResolvedAuthSession { function makeDirectEnvApiKeySession(params: { apiKey: string - envVarName: 'NOUMENA_API_KEY' | 'ANTHROPIC_API_KEY' + envVarName: 'NOUMENA_API_KEY' | 'ANTHROPIC_API_KEY' | 'OPENAI_API_KEY' providerMode: 'noumena_managed' | 'byok_static_env' + headersKind?: 'api_key' | 'none' }): ResolvedAuthSession { return { principalKind: 'api_key_user', principalSource: 'direct_api_key_env', sessionState: 'usable', - headersKind: 'api_key', + headersKind: params.headersKind ?? 'api_key', providerAuthKind: params.providerMode === 'noumena_managed' ? 'noumena_first_party' @@ -277,6 +280,31 @@ describe('errors canonical auth classification', () => { ]) }) + it('classifies OpenAI-compatible env API key failures as external invalid-key errors', () => { + process.env.OPENAI_API_KEY = 'openai-static-env-key' + + const message = withMockCurrentSession( + makeDirectEnvApiKeySession({ + apiKey: 'openai-static-env-key', + envVarName: 'OPENAI_API_KEY', + providerMode: 'byok_static_env', + headersKind: 'none', + }), + () => + getAssistantMessageFromError( + new Error('Authorization bearer rejected'), + 'gpt-5.1-codex', + ), + ) + + expect(message.message.content).toEqual([ + expect.objectContaining({ + text: INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL, + type: 'text', + }), + ]) + }) + it('keeps the default login guidance when there is no external API key session', () => { const message = getAssistantMessageFromError( new Error('x-api-key rejected'), diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index 7f4df37..07659b2 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -236,7 +236,8 @@ function isCurrentExternalEnvApiKeySession(): boolean { const session = getCurrentErrorSession() return ( session?.rawApiKeySource === 'NOUMENA_API_KEY' || - session?.rawApiKeySource === 'ANTHROPIC_API_KEY' + session?.rawApiKeySource === 'ANTHROPIC_API_KEY' || + session?.rawApiKeySource === 'OPENAI_API_KEY' ) } @@ -864,10 +865,15 @@ export function getAssistantMessageFromError( } } - if ( - error instanceof Error && - error.message.toLowerCase().includes('x-api-key') - ) { + const lowerErrorMessage = + error instanceof Error ? error.message.toLowerCase() : '' + const isApiKeyAuthError = + lowerErrorMessage.includes('x-api-key') || + (isCurrentExternalApiKeySession() && + (lowerErrorMessage.includes('authorization') || + lowerErrorMessage.includes('bearer'))) + + if (error instanceof Error && isApiKeyAuthError) { // In CCR mode, auth is via JWTs - this is likely a transient network issue if (isCCRMode()) { return createAssistantAPIErrorMessage({ diff --git a/src/services/api/inferenceClient.test.ts b/src/services/api/inferenceClient.test.ts index 18a9ed6..e33510c 100644 --- a/src/services/api/inferenceClient.test.ts +++ b/src/services/api/inferenceClient.test.ts @@ -23,6 +23,9 @@ const envKeys = [ 'CLAUDE_CODE_USE_FOUNDRY', 'ANTHROPIC_API_KEY', 'NOUMENA_API_KEY', + 'OPENAI_API_KEY', + 'OPENAI_BASE_URL', + 'OPENAI_MODEL', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'NCODE_CONFIG_DIR', @@ -59,6 +62,9 @@ function setStableTestRuntime() { delete process.env.CLAUDE_CODE_USE_VERTEX delete process.env.CLAUDE_CODE_USE_FOUNDRY delete process.env.NOUMENA_API_KEY + delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_MODEL delete process.env.CI process.env.ANTHROPIC_API_KEY = 'anthropic-direct-test-key' @@ -310,4 +316,66 @@ describe('getInferenceClient', () => { expect(request.headers.get('authorization')).toBeNull() expect(request.headers.get('x-api-key')).toBe('byok-static-env-key') }) + + + it('routes OPENAI_API_KEY through the OpenAI-compatible client without Noumena auth headers', async () => { + delete process.env.NOUMENA_BASE_URL + delete process.env.ANTHROPIC_BASE_URL + delete process.env.NOUMENA_API_KEY + delete process.env.ANTHROPIC_API_KEY + delete process.env.ANTHROPIC_AUTH_TOKEN + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + process.env.OPENAI_API_KEY = 'openai-static-env-key' + + const recorder = createModelsFetchRecorder() + + const client = await getInferenceClient({ + maxRetries: 2, + source: 'openai-byok', + fetchOverride: recorder.fetchOverride, + }) + + expect(client).toBeInstanceOf(OpenAICompatInferenceClient) + expect(await collectModels(client as OpenAICompatInferenceClient)).toEqual([ + { id: 'test-model' }, + ]) + + const request = recorder.getRequest() + expect(request.url).toBe('https://api.openai.com/v1/models') + expect(request.headers.get('authorization')).toBe( + 'Bearer openai-static-env-key', + ) + expect(request.headers.get('x-api-key')).toBeNull() + expect(request.headers.get('anthropic-beta')).toBeNull() + expect(request.headers.get('x-client-request-id')).toBeNull() + }) + + it('preserves path-prefixed OPENAI_BASE_URL values for OpenAI-compatible BYOK', async () => { + delete process.env.NOUMENA_BASE_URL + delete process.env.ANTHROPIC_BASE_URL + delete process.env.NOUMENA_API_KEY + delete process.env.ANTHROPIC_API_KEY + delete process.env.ANTHROPIC_AUTH_TOKEN + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + process.env.OPENAI_API_KEY = 'openai-static-env-key' + process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' + + const recorder = createModelsFetchRecorder() + + const client = await getInferenceClient({ + maxRetries: 2, + source: 'openrouter-byok', + fetchOverride: recorder.fetchOverride, + }) + + expect(client).toBeInstanceOf(OpenAICompatInferenceClient) + expect(await collectModels(client as OpenAICompatInferenceClient)).toEqual([ + { id: 'test-model' }, + ]) + + expect(recorder.getRequest().url).toBe( + 'https://openrouter.ai/api/v1/models', + ) + }) + }) diff --git a/src/services/api/inferenceClient.ts b/src/services/api/inferenceClient.ts index 95b97a7..a4f5480 100644 --- a/src/services/api/inferenceClient.ts +++ b/src/services/api/inferenceClient.ts @@ -2,6 +2,8 @@ import type Anthropic from '@anthropic-ai/sdk' import { getAPIProvider, getNoumenaBaseUrl, + getOpenAICompatBaseUrl, + isOpenAICompatByokActive, isFirstPartyNoumenaBaseUrl, } from '../../utils/model/providers.js' import { @@ -11,6 +13,8 @@ import { } from './client.js' import { OpenAICompatInferenceClient } from './openAICompatInferenceClient.js' import { getNCodeManagedModelBaseUrl } from '../../utils/model/ncodeModels.js' +import { getDirectApiKeyEnvValue } from '../../utils/authEnv.js' +import { getUserAgent } from '../../utils/http.js' export type InferenceCreateMessageArgs = Parameters< Anthropic['beta']['messages']['create'] @@ -72,9 +76,30 @@ function getLegacyOpenAICompatBaseUrl(): string | undefined { return isFirstPartyNoumenaBaseUrl() ? undefined : legacyBaseUrl } +function getOpenAICompatByokHeaders(apiKey: string): Record { + return { + Authorization: `Bearer ${apiKey}`, + 'User-Agent': getUserAgent(), + } +} + export async function getInferenceClient( args: Parameters[0], ): Promise { + if (isOpenAICompatByokActive()) { + const apiKey = getDirectApiKeyEnvValue() + const baseURL = getOpenAICompatBaseUrl() + if (apiKey && baseURL) { + return new OpenAICompatInferenceClient({ + baseURL, + headers: getOpenAICompatByokHeaders(apiKey), + useNCodeManagedModelRouting: false, + wsV2Transport: null, + ...(args.fetchOverride ? { fetch: args.fetchOverride } : {}), + }) + } + } + if (getAPIProvider() === 'firstParty') { const managedModelBaseURL = getNCodeManagedModelBaseUrl(args.model) const configuredCompatBaseURL = diff --git a/src/services/api/openAICompatInferenceClient.test.ts b/src/services/api/openAICompatInferenceClient.test.ts index e8fdd04..09df1c7 100644 --- a/src/services/api/openAICompatInferenceClient.test.ts +++ b/src/services/api/openAICompatInferenceClient.test.ts @@ -170,6 +170,19 @@ describe('buildOpenAICompatChatRequest', () => { }) }) + it('does not rewrite model IDs when NCode managed routing is disabled', () => { + const request = buildOpenAICompatChatRequest( + { + model: 'glm-5.2', + max_tokens: 32, + messages: [{ role: 'user', content: 'hello' }], + } as never, + { useNCodeManagedModelRouting: false }, + ) + + expect(request.model).toBe('glm-5.2') + }) + it('maps NCode effort levels onto OpenAI-compatible reasoning effort per request', () => { for (const effort of ['medium', 'high', 'max'] as const) { const request = buildOpenAICompatChatRequest({ @@ -1830,6 +1843,63 @@ describe('OpenAICompatInferenceClient', () => { ]) }) + it('supports external OpenAI-compatible BYOK routing without Noumena rewrites', async () => { + const fetchCalls: Array<{ + url: string + headers: Headers + body: Record | null + }> = [] + const client = new OpenAICompatInferenceClient({ + baseURL: 'https://openrouter.ai/api/v1', + headers: { + Authorization: 'Bearer openai-key', + }, + useNCodeManagedModelRouting: false, + wsV2Transport: null, + fetch: async (input, init) => { + fetchCalls.push({ + url: String(input), + headers: new Headers(init?.headers), + body: + typeof init?.body === 'string' + ? (JSON.parse(init.body) as Record) + : null, + }) + return new Response( + JSON.stringify({ + id: 'chatcmpl-openai-byok', + model: 'glm-5.2', + choices: [ + { + message: { + role: 'assistant', + content: 'ok', + }, + finish_reason: 'stop', + }, + ], + }), + ) + }, + }) + + await client.createMessage({ + model: 'glm-5.2', + max_tokens: 8, + messages: [{ role: 'user', content: 'hello' }], + } as never) + + expect(fetchCalls).toHaveLength(1) + expect(fetchCalls[0]!.url).toBe( + 'https://openrouter.ai/api/v1/chat/completions', + ) + expect(fetchCalls[0]!.headers.get('authorization')).toBe( + 'Bearer openai-key', + ) + expect(fetchCalls[0]!.headers.get('x-noumena-model')).toBeNull() + expect(fetchCalls[0]!.body?.model).toBe('glm-5.2') + }) + it('routes chat completions by the actual request model, not the stale client model', async () => { const fetchCalls: Array = [] const client = new OpenAICompatInferenceClient({ diff --git a/src/services/api/openAICompatInferenceClient.ts b/src/services/api/openAICompatInferenceClient.ts index eabb8d1..3dbba55 100644 --- a/src/services/api/openAICompatInferenceClient.ts +++ b/src/services/api/openAICompatInferenceClient.ts @@ -32,6 +32,7 @@ type OpenAICompatInferenceClientOptions = { headers?: HeadersInit backendCapabilities?: Partial wsV2Transport?: OpenAICompatWsV2Transport | null + useNCodeManagedModelRouting?: boolean } type OpenAICompatRequestPolicy = { @@ -228,8 +229,15 @@ type OpenAIModelsResponse = { const OPENAI_COMPAT_REASONING_TRANSPORT_ENV = 'NOUMENA_OPENAI_COMPAT_REASONING_TRANSPORT' -function normalizeOpenAICompatModelForAPI(model: string): string { - return (resolveNCodeManagedModel(model)?.model ?? model).replace(/\[(1|2)m\]/gi, '') +function normalizeOpenAICompatModelForAPI( + model: string, + options?: { useNCodeManagedModelRouting?: boolean }, +): string { + const normalized = + options?.useNCodeManagedModelRouting === false + ? model + : (resolveNCodeManagedModel(model)?.model ?? model) + return normalized.replace(/\[(1|2)m\]/gi, '') } function getNoumenaModelRoutingHeader(model: string): string | undefined { @@ -805,6 +813,7 @@ export function buildOpenAICompatChatRequest( params: InferenceCreateMessageArgs[0], options?: { policy?: OpenAICompatRequestPolicy + useNCodeManagedModelRouting?: boolean }, ): OpenAIChatCompletionRequest { const customParams: Record = {} @@ -839,7 +848,9 @@ export function buildOpenAICompatChatRequest( const convertedMessages = convertMessages(params.system, params.messages) const request: OpenAIChatCompletionRequest = { - model: normalizeOpenAICompatModelForAPI(params.model), + model: normalizeOpenAICompatModelForAPI(params.model, { + useNCodeManagedModelRouting: options?.useNCodeManagedModelRouting, + }), messages: convertedMessages, max_completion_tokens: params.max_tokens, ...(params.max_tokens !== undefined ? { max_tokens: params.max_tokens } : {}), @@ -1576,15 +1587,29 @@ export class OpenAICompatInferenceClient implements InferenceClient { private buildURLForModel(path: string, model: string): string { return this.buildURLWithBase( path, - getNCodeManagedModelBaseUrl(model) ?? this.options.baseURL, + this.shouldUseNCodeManagedModelRouting() + ? (getNCodeManagedModelBaseUrl(model) ?? this.options.baseURL) + : this.options.baseURL, ) } private buildURLWithBase(path: string, baseURL: string): string { - return new URL( - path, - baseURL.endsWith('/') ? baseURL : `${baseURL}/`, - ).toString() + const base = new URL(baseURL) + const basePath = base.pathname.replace(/\/+$/, '') + const requestPath = path.replace(/^\/+/, '') + const pathToAppend = + basePath.endsWith('/v1') && requestPath.startsWith('v1/') + ? requestPath.slice('v1/'.length) + : requestPath + base.pathname = [basePath, pathToAppend] + .filter(Boolean) + .join('/') + .replace(/\/{2,}/g, '/') + return base.toString() + } + + private shouldUseNCodeManagedModelRouting(): boolean { + return this.options.useNCodeManagedModelRouting ?? true } private async postJSON( @@ -1610,7 +1635,7 @@ export class OpenAICompatInferenceClient implements InferenceClient { ? body.model : undefined const routingHeaderModel = init?.model ?? bodyModel - const routingModel = routingHeaderModel + const routingModel = this.shouldUseNCodeManagedModelRouting() && routingHeaderModel ? getNoumenaModelRoutingHeader(routingHeaderModel) : undefined if (routingModel) { @@ -1645,7 +1670,9 @@ export class OpenAICompatInferenceClient implements InferenceClient { for (const [key, value] of new Headers(init?.headers).entries()) { headers.set(key, value) } - const routingModel = model ? getNoumenaModelRoutingHeader(model) : undefined + const routingModel = this.shouldUseNCodeManagedModelRouting() && model + ? getNoumenaModelRoutingHeader(model) + : undefined if (routingModel) { headers.set('x-noumena-model', routingModel) } @@ -1697,6 +1724,7 @@ export class OpenAICompatInferenceClient implements InferenceClient { ) const request = buildOpenAICompatChatRequest(params, { policy: requestPolicy, + useNCodeManagedModelRouting: this.shouldUseNCodeManagedModelRouting(), }) const apiModel = request.model const wsV2Transport = params.stream ? this.getWsV2Transport() : null diff --git a/src/services/compact/autoCompact.ncodeModels.test.ts b/src/services/compact/autoCompact.ncodeModels.test.ts index 29b1f81..282781b 100644 --- a/src/services/compact/autoCompact.ncodeModels.test.ts +++ b/src/services/compact/autoCompact.ncodeModels.test.ts @@ -7,6 +7,8 @@ import { import { DEEPSEEK_V4_FLASH_MAX_PROMPT_TOKENS, DEEPSEEK_V4_FLASH_MODEL, + GLM_5_2_1M_MAX_PROMPT_TOKENS, + GLM_5_2_1M_MODEL, GLM_5_2_MAX_PROMPT_TOKENS, GLM_5_2_MODEL, KIMI_2_7_CODER_MODEL, @@ -40,10 +42,34 @@ describe('auto compact managed model prompt budgets', () => { ['glm alias', 'glm-5.2'], ['glm compact alias', 'glm52'], ['glm model', GLM_5_2_MODEL], - ])('%s uses the GLM 5.2 1M autocompact budget', (_label, model) => { + ])('%s uses the GLM 5.2 200K base-lane autocompact budget', (_label, model) => { expect(getEffectiveContextWindowSize(model)).toBe( GLM_5_2_MAX_PROMPT_TOKENS, ) + expect(getAutoCompactThreshold(model)).toBe(187_000) + + expect(calculateTokenWarningState(180_000, model)).toMatchObject({ + isAboveAutoCompactThreshold: false, + isAtBlockingLimit: false, + }) + expect(calculateTokenWarningState(195_000, model)).toMatchObject({ + isAboveAutoCompactThreshold: true, + isAtBlockingLimit: false, + }) + expect(calculateTokenWarningState(198_000, model)).toMatchObject({ + isAboveAutoCompactThreshold: true, + isAtBlockingLimit: true, + }) + }) + + test.each([ + ['glm 1m alias', 'glm-5.2[1m]'], + ['glm 1m compact alias', 'glm52[1m]'], + ['glm 1m model', GLM_5_2_1M_MODEL], + ])('%s uses the GLM 5.2 1M autocompact budget', (_label, model) => { + expect(getEffectiveContextWindowSize(model)).toBe( + GLM_5_2_1M_MAX_PROMPT_TOKENS, + ) expect(getAutoCompactThreshold(model)).toBe(987_000) expect(calculateTokenWarningState(980_000, model)).toMatchObject({ diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 6d18800..aca63df 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -95,8 +95,8 @@ const DEFAULT_API_KEY_HELPER_TTL = 5 * 60 * 1000 * CCR and Claude Desktop spawn the CLI with OAuth and should never fall back * to the user's settings-backed API-key config (typically * ~/.ncode/settings.json; legacy ~/.claude/settings.json) or related auth env - * sources (apiKeyHelper, env.NOUMENA_API_KEY / env.ANTHROPIC_API_KEY, - * env.ANTHROPIC_AUTH_TOKEN). Those settings exist for the user's terminal + * sources (apiKeyHelper, env.NOUMENA_API_KEY / env.ANTHROPIC_API_KEY / + * env.OPENAI_API_KEY, env.ANTHROPIC_AUTH_TOKEN). Those settings exist for the user's terminal * CLI, not managed sessions. Without this guard, a user who runs `claude` in * their terminal with an API key sees every CCD session also use that key — * and fail if it's stale/wrong-org. @@ -122,8 +122,8 @@ export function isAnthropicAuthEnabled(): boolean { // oauth-2025 beta header to match what the proxy will inject). The remote's // settings-backed config (typically ~/.ncode/settings.json; legacy // ~/.claude/settings.json) MUST NOT - // allow apiKeyHelper, settings.env.NOUMENA_API_KEY, or - // settings.env.ANTHROPIC_API_KEY to + // allow apiKeyHelper, settings.env.NOUMENA_API_KEY, + // settings.env.ANTHROPIC_API_KEY, or settings.env.OPENAI_API_KEY to // flip this — they'd cause a header mismatch with the proxy and a bogus // "invalid x-api-key" from the API. See src/ssh/sshAuthProxy.ts. if (process.env.ANTHROPIC_UNIX_SOCKET) { @@ -160,6 +160,7 @@ export function isAnthropicAuthEnabled(): boolean { const hasExternalApiKey = apiKeySource === 'NOUMENA_API_KEY' || apiKeySource === 'ANTHROPIC_API_KEY' || + apiKeySource === 'OPENAI_API_KEY' || apiKeySource === 'apiKeyHelper' // Disable Anthropic auth if: @@ -271,6 +272,7 @@ export function getAuthTokenSource() { export type ApiKeySource = | 'NOUMENA_API_KEY' | 'ANTHROPIC_API_KEY' + | 'OPENAI_API_KEY' | 'apiKeyHelper' | '/login managed key' | 'none' @@ -293,9 +295,10 @@ export function getAnthropicApiKeyWithSource( key: null | string source: ApiKeySource } { - // --bare: hermetic auth. Only ANTHROPIC_API_KEY env or apiKeyHelper from - // the --settings flag. Never touches keychain, config file, or approval - // lists. 3P (Bedrock/Vertex/Foundry) uses provider creds, not this path. + // --bare: hermetic static-key auth. Only direct env keys or apiKeyHelper from + // the --settings flag are read. Never touches keychain, config file, or + // approval lists. 3P (Bedrock/Vertex/Foundry) uses provider creds, not this + // path. if (isBareMode()) { const directApiKey = getDirectApiKeyEnvValue() if (directApiKey) { @@ -348,7 +351,7 @@ export function getAnthropicApiKeyWithSource( !getOAuthTokenFileDescriptorEnvVarName() ) { throw new Error( - 'NOUMENA_API_KEY / ANTHROPIC_API_KEY or NCODE_OAUTH_TOKEN / NCODE_OAUTH_TOKEN_FILE_DESCRIPTOR env var is required', + 'NOUMENA_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY or NCODE_OAUTH_TOKEN / NCODE_OAUTH_TOKEN_FILE_DESCRIPTOR env var is required', ) } @@ -365,7 +368,8 @@ export function getAnthropicApiKeyWithSource( source: 'none', } } - // Check for ANTHROPIC_API_KEY before checking the apiKeyHelper or /login-managed key + // Check for direct API-key env vars before checking apiKeyHelper or + // /login-managed key. if ( apiKeyEnv && getGlobalConfig().customApiKeyResponses?.approved?.includes( diff --git a/src/utils/authEnv.test.ts b/src/utils/authEnv.test.ts index 7b7f002..1b93f82 100644 --- a/src/utils/authEnv.test.ts +++ b/src/utils/authEnv.test.ts @@ -1,12 +1,14 @@ import { afterEach, describe, expect, it } from 'bun:test' import { getDirectApiKeyEnvValue, + getDirectApiKeyProviderKind, getDirectApiKeyProviderMode, getDirectApiKeyEnvVarName, } from './authEnv.js' const originalNoumenaApiKey = process.env.NOUMENA_API_KEY const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY +const originalOpenAIApiKey = process.env.OPENAI_API_KEY afterEach(() => { if (originalNoumenaApiKey === undefined) { @@ -20,24 +22,45 @@ afterEach(() => { } else { process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey } + + if (originalOpenAIApiKey === undefined) { + delete process.env.OPENAI_API_KEY + } else { + process.env.OPENAI_API_KEY = originalOpenAIApiKey + } }) describe('auth env helpers', () => { - it('prefers NOUMENA_API_KEY when both aliases are set', () => { + it('prefers NOUMENA_API_KEY when all direct key aliases are set', () => { process.env.NOUMENA_API_KEY = 'noumena-key' process.env.ANTHROPIC_API_KEY = 'anthropic-key' + process.env.OPENAI_API_KEY = 'openai-key' expect(getDirectApiKeyEnvVarName()).toBe('NOUMENA_API_KEY') expect(getDirectApiKeyEnvValue()).toBe('noumena-key') expect(getDirectApiKeyProviderMode()).toBe('noumena_managed') + expect(getDirectApiKeyProviderKind()).toBe('noumena') }) - it('falls back to ANTHROPIC_API_KEY when the Noumena alias is absent', () => { + it('prefers ANTHROPIC_API_KEY over OPENAI_API_KEY for backwards compatibility', () => { delete process.env.NOUMENA_API_KEY process.env.ANTHROPIC_API_KEY = 'anthropic-key' + process.env.OPENAI_API_KEY = 'openai-key' expect(getDirectApiKeyEnvVarName()).toBe('ANTHROPIC_API_KEY') expect(getDirectApiKeyEnvValue()).toBe('anthropic-key') expect(getDirectApiKeyProviderMode()).toBe('byok_static_env') + expect(getDirectApiKeyProviderKind()).toBe('anthropic') + }) + + it('falls back to OPENAI_API_KEY as OpenAI-compatible BYOK', () => { + delete process.env.NOUMENA_API_KEY + delete process.env.ANTHROPIC_API_KEY + process.env.OPENAI_API_KEY = 'openai-key' + + expect(getDirectApiKeyEnvVarName()).toBe('OPENAI_API_KEY') + expect(getDirectApiKeyEnvValue()).toBe('openai-key') + expect(getDirectApiKeyProviderMode()).toBe('byok_static_env') + expect(getDirectApiKeyProviderKind()).toBe('openai_compat') }) }) diff --git a/src/utils/authEnv.ts b/src/utils/authEnv.ts index ee2dbe5..a394e52 100644 --- a/src/utils/authEnv.ts +++ b/src/utils/authEnv.ts @@ -1,5 +1,12 @@ -export type DirectApiKeyEnvVarName = 'NOUMENA_API_KEY' | 'ANTHROPIC_API_KEY' +export type DirectApiKeyEnvVarName = + | 'NOUMENA_API_KEY' + | 'ANTHROPIC_API_KEY' + | 'OPENAI_API_KEY' export type DirectApiKeyProviderMode = 'noumena_managed' | 'byok_static_env' +export type DirectApiKeyProviderKind = + | 'noumena' + | 'anthropic' + | 'openai_compat' export function getDirectApiKeyEnvVarName(): DirectApiKeyEnvVarName | null { if (process.env.NOUMENA_API_KEY) { @@ -8,6 +15,9 @@ export function getDirectApiKeyEnvVarName(): DirectApiKeyEnvVarName | null { if (process.env.ANTHROPIC_API_KEY) { return 'ANTHROPIC_API_KEY' } + if (process.env.OPENAI_API_KEY) { + return 'OPENAI_API_KEY' + } return null } @@ -25,5 +35,59 @@ export function getDirectApiKeyProviderMode( if (envVarName === 'ANTHROPIC_API_KEY') { return 'byok_static_env' } + if (envVarName === 'OPENAI_API_KEY') { + return 'byok_static_env' + } return null } + +export function isDirectApiKeyEnvVarName( + value: string | null | undefined, +): value is DirectApiKeyEnvVarName { + return ( + value === 'NOUMENA_API_KEY' || + value === 'ANTHROPIC_API_KEY' || + value === 'OPENAI_API_KEY' + ) +} + +export function getDirectApiKeyProviderKind( + envVarName: DirectApiKeyEnvVarName | null = getDirectApiKeyEnvVarName(), +): DirectApiKeyProviderKind | null { + switch (envVarName) { + case 'NOUMENA_API_KEY': + return 'noumena' + case 'ANTHROPIC_API_KEY': + return 'anthropic' + case 'OPENAI_API_KEY': + return 'openai_compat' + default: + return null + } +} + +export function getDirectApiKeyProviderKindForSource( + source: string | null | undefined, +): DirectApiKeyProviderKind | null { + return isDirectApiKeyEnvVarName(source) + ? getDirectApiKeyProviderKind(source) + : null +} + +export function isOpenAIDirectApiKeyEnvVar( + envVarName: DirectApiKeyEnvVarName | null = getDirectApiKeyEnvVarName(), +): boolean { + return getDirectApiKeyProviderKind(envVarName) === 'openai_compat' +} + +export function isOpenAIDirectApiKeySource( + source: string | null | undefined, +): boolean { + return getDirectApiKeyProviderKindForSource(source) === 'openai_compat' +} + +export function isAnthropicDirectApiKeySource( + source: string | null | undefined, +): boolean { + return getDirectApiKeyProviderKindForSource(source) === 'anthropic' +} diff --git a/src/utils/context.1m-tier-contract.test.ts b/src/utils/context.1m-tier-contract.test.ts index a773434..3e7ac62 100644 --- a/src/utils/context.1m-tier-contract.test.ts +++ b/src/utils/context.1m-tier-contract.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { getContextWindowForModel } from './context.js' import { DEEPSEEK_V4_FLASH_MODEL, + GLM_5_2_1M_MODEL, GLM_5_2_MODEL, KIMI_2_7_CODER_MODEL, } from './model/ncodeModels.js' @@ -16,8 +17,12 @@ describe('managed [1m] tier-tag contract (P0 #4)', () => { expect(getContextWindowForModel(`${KIMI_2_7_CODER_MODEL}[1m]`)).toBe(200_000) }) - test('GLM model ID + [1m] tag stays at 1M (natively 1M, tag is redundant)', () => { - expect(getContextWindowForModel(GLM_5_2_MODEL)).toBe(1_000_000) + // b248c43 split GLM 5.2 into a 200K base lane and an explicit `[1m]` 1M + // lane, so the bare model ID and bare aliases resolve to the 200K managed + // lane. Only the `[1m]`-suffixed model ID lands on the 1M lane. + test('GLM 5.2 base lane stays at 200K; [1m] lane is 1M', () => { + expect(getContextWindowForModel(GLM_5_2_MODEL)).toBe(200_000) + expect(getContextWindowForModel(GLM_5_2_1M_MODEL)).toBe(1_000_000) expect(getContextWindowForModel(`${GLM_5_2_MODEL}[1m]`)).toBe(1_000_000) }) @@ -26,9 +31,11 @@ describe('managed [1m] tier-tag contract (P0 #4)', () => { expect(getContextWindowForModel(`${DEEPSEEK_V4_FLASH_MODEL}[1m]`)).toBe(1_000_000) }) - test('managed aliases via alias strings resolve through profile lookup first', () => { - expect(getContextWindowForModel('glm-5.2')).toBe(1_000_000) - expect(getContextWindowForModel('glm52')).toBe(1_000_000) + test('managed aliases resolve through profile lookup (GLM base lane is 200K, [1m] aliases are 1M)', () => { + expect(getContextWindowForModel('glm-5.2')).toBe(200_000) + expect(getContextWindowForModel('glm52')).toBe(200_000) + expect(getContextWindowForModel('glm-5.2[1m]')).toBe(1_000_000) + expect(getContextWindowForModel('glm52[1m]')).toBe(1_000_000) expect(getContextWindowForModel('kimi-2.7-coder')).toBe(200_000) expect(getContextWindowForModel('deepseek-v4-flash')).toBe(1_000_000) expect(getContextWindowForModel('dsv4-flash')).toBe(1_000_000) diff --git a/src/utils/http.ts b/src/utils/http.ts index 59c6c2d..a10c160 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -106,6 +106,7 @@ export function getAuthHeaders(): AuthHeaders { (session.principalSource === 'direct_api_key_env' || session.principalSource === 'console_api_key' || session.principalSource === 'api_key_helper') && + session.rawApiKeySource !== 'OPENAI_API_KEY' && session.apiKey ) { return { diff --git a/src/utils/managedEnv.ts b/src/utils/managedEnv.ts index 5d24dc3..b47be50 100644 --- a/src/utils/managedEnv.ts +++ b/src/utils/managedEnv.ts @@ -9,6 +9,7 @@ import { import { clearMTLSCache } from './mtls.js' import { clearProxyCache, configureGlobalAgents } from './proxy.js' import { isSettingSourceEnabled } from './settings/constants.js' +import type { SettingSource } from './settings/constants.js' import { getSettings_DEPRECATED, getSettingsForSource, @@ -29,8 +30,11 @@ function withoutSSHTunnelVars( ANTHROPIC_BASE_URL: _2, NOUMENA_API_KEY: _3, ANTHROPIC_API_KEY: _4, - ANTHROPIC_AUTH_TOKEN: _5, - CLAUDE_CODE_OAUTH_TOKEN: _6, + OPENAI_API_KEY: _5, + OPENAI_BASE_URL: _6, + OPENAI_MODEL: _7, + ANTHROPIC_AUTH_TOKEN: _8, + CLAUDE_CODE_OAUTH_TOKEN: _9, ...rest } = env return rest @@ -80,14 +84,39 @@ function withoutCcdSpawnEnvKeys( return out } +const PROJECT_SETTINGS_OPENAI_BYOK_ENV_VARS = new Set([ + 'OPENAI_API_KEY', + 'OPENAI_BASE_URL', + 'OPENAI_MODEL', +]) + +function withoutProjectSettingsOpenAIByokVars( + env: Record | undefined, + source?: SettingSource, +): Record { + if (!env || source !== 'projectSettings') return env || {} + const out: Record = {} + for (const [key, value] of Object.entries(env)) { + if (!PROJECT_SETTINGS_OPENAI_BYOK_ENV_VARS.has(key.toUpperCase())) { + out[key] = value + } + } + return out +} + /** * Compose the strip filters applied to every settings-sourced env object. */ function filterSettingsEnv( env: Record | undefined, + source?: SettingSource, ): Record { return withoutCcdSpawnEnvKeys( - withoutHostManagedProviderVars(withoutSSHTunnelVars(env)), + withoutHostManagedProviderVars( + withoutSSHTunnelVars( + withoutProjectSettingsOpenAIByokVars(env, source), + ), + ), ) } @@ -109,6 +138,14 @@ const TRUSTED_SETTING_SOURCES = [ 'policySettings', ] as const +const SETTINGS_ENV_APPLICATION_ORDER: readonly SettingSource[] = [ + 'userSettings', + 'projectSettings', + 'localSettings', + 'flagSettings', + 'policySettings', +] + /** * Apply environment variables from trusted sources to process.env. * Called before the trust dialog so that user/enterprise env vars like @@ -145,7 +182,7 @@ export function applySafeConfigEnvironmentVariables(): void { if (!isSettingSourceEnabled(source)) continue Object.assign( process.env, - filterSettingsEnv(getSettingsForSource(source)?.env), + filterSettingsEnv(getSettingsForSource(source)?.env, source), ) } @@ -159,7 +196,10 @@ export function applySafeConfigEnvironmentVariables(): void { Object.assign( process.env, - filterSettingsEnv(getSettingsForSource('policySettings')?.env), + filterSettingsEnv( + getSettingsForSource('policySettings')?.env, + 'policySettings', + ), ) // Apply only safe env vars from the fully-merged settings (which includes @@ -188,7 +228,15 @@ export function applySafeConfigEnvironmentVariables(): void { export function applyConfigEnvironmentVariables(): void { Object.assign(process.env, filterSettingsEnv(getGlobalConfig().env)) - Object.assign(process.env, filterSettingsEnv(getSettings_DEPRECATED()?.env)) + for (const source of SETTINGS_ENV_APPLICATION_ORDER) { + if (source !== 'flagSettings' && source !== 'policySettings') { + if (!isSettingSourceEnabled(source)) continue + } + Object.assign( + process.env, + filterSettingsEnv(getSettingsForSource(source)?.env, source), + ) + } // Clear caches so agents are rebuilt with the new env vars clearCACertsCache() diff --git a/src/utils/managedEnvConstants.test.ts b/src/utils/managedEnvConstants.test.ts index 7baaffb..f3073a7 100644 --- a/src/utils/managedEnvConstants.test.ts +++ b/src/utils/managedEnvConstants.test.ts @@ -15,4 +15,13 @@ describe('managedEnvConstants GrowthBook ownership', () => { expect(SAFE_ENV_VARS.has(key)).toBe(false) } }) + + + it('treats OpenAI-compatible BYOK routing and auth vars as provider-managed and unsafe', () => { + for (const key of ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL']) { + expect(isProviderManagedEnvVar(key)).toBe(true) + expect(SAFE_ENV_VARS.has(key)).toBe(false) + } + }) + }) diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index ea6ab1f..a7e9334 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -27,6 +27,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'NOUMENA_GROWTHBOOK_API_HOST', 'NOUMENA_GROWTHBOOK_CLIENT_KEY', 'ANTHROPIC_BASE_URL', + 'OPENAI_BASE_URL', 'ANTHROPIC_BEDROCK_BASE_URL', 'ANTHROPIC_VERTEX_BASE_URL', 'ANTHROPIC_FOUNDRY_BASE_URL', @@ -38,6 +39,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'NOUMENA_OAUTH_CLIENT_ID', 'NOUMENA_API_KEY', 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_CLIENT_ID', 'CLAUDE_CODE_OAUTH_TOKEN', @@ -48,6 +50,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH', // Model defaults — often set to provider-specific ID formats 'NOUMENA_MODEL', + 'OPENAI_MODEL', // Temporary Anthropic-family carryover for launch pragmatism. Replace these // with Noumena-native tier names once the model contract is fully renamed. 'NOUMENA_DEFAULT_HAIKU_MODEL', @@ -109,7 +112,7 @@ export const DANGEROUS_SHELL_SETTINGS = [ * Dangerous env vars (NOT in this list): * * === REDIRECT TO ATTACKER-CONTROLLED SERVER === - * - NOUMENA_BASE_URL, NOUMENA_PLATFORM_BASE_URL, NOUMENA_ISSUER_BASE_URL, NOUMENA_GROWTHBOOK_API_HOST, NOUMENA_GROWTHBOOK_CLIENT_KEY, ANTHROPIC_BASE_URL, ANTHROPIC_BEDROCK_BASE_URL, ANTHROPIC_FOUNDRY_BASE_URL, ANTHROPIC_VERTEX_BASE_URL + * - NOUMENA_BASE_URL, NOUMENA_PLATFORM_BASE_URL, NOUMENA_ISSUER_BASE_URL, NOUMENA_GROWTHBOOK_API_HOST, NOUMENA_GROWTHBOOK_CLIENT_KEY, ANTHROPIC_BASE_URL, OPENAI_BASE_URL, ANTHROPIC_BEDROCK_BASE_URL, ANTHROPIC_FOUNDRY_BASE_URL, ANTHROPIC_VERTEX_BASE_URL * - NOUMENA_OAUTH_WEB_BASE_URL * - HTTP_PROXY, HTTPS_PROXY, NO_PROXY, http_proxy, https_proxy, no_proxy * - OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, OTEL_EXPORTER_OTLP_METRICS_ENDPOINT @@ -120,7 +123,7 @@ export const DANGEROUS_SHELL_SETTINGS = [ * * === SWITCH TO ATTACKER-CONTROLLED PROJECT === * - ANTHROPIC_FOUNDRY_RESOURCE - * - NOUMENA_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN + * - NOUMENA_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, ANTHROPIC_AUTH_TOKEN * - AWS_BEARER_TOKEN_BEDROCK */ export const SAFE_ENV_VARS = new Set([ diff --git a/src/utils/model/model.auth.test.ts b/src/utils/model/model.auth.test.ts index dbc31ee..561cb6b 100644 --- a/src/utils/model/model.auth.test.ts +++ b/src/utils/model/model.auth.test.ts @@ -2,9 +2,12 @@ import { afterEach, describe, expect, it } from 'bun:test' import { getAuthRuntime } from '../../auth/runtime/AuthRuntime.js' import type { ResolvedAuthSession } from '../../auth/runtime/types.js' import { + getDefaultFlashModel, getDefaultMainLoopModelSetting, + getDefaultOpusModel, isOpus1mMergeEnabled, } from './model.js' +import { GLM_5_2_MODEL } from './ncodeModels.js' const originalEntryPoint = process.env.CLAUDE_CODE_ENTRYPOINT const originalUserType = process.env.USER_TYPE @@ -13,6 +16,8 @@ const originalDisable1m = process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT const originalUseBedrock = process.env.CLAUDE_CODE_USE_BEDROCK const originalUseVertex = process.env.CLAUDE_CODE_USE_VERTEX const originalUseFoundry = process.env.CLAUDE_CODE_USE_FOUNDRY +const originalOpenAIApiKey = process.env.OPENAI_API_KEY +const originalOpenAIModel = process.env.OPENAI_MODEL const originalDefaultOpus = process.env.NOUMENA_DEFAULT_OPUS_MODEL const originalDefaultSonnet = process.env.NOUMENA_DEFAULT_SONNET_MODEL const originalDefaultHaiku = process.env.NOUMENA_DEFAULT_HAIKU_MODEL @@ -147,7 +152,7 @@ afterEach(() => { }) describe('model auth session gating', () => { - it('defaults noumena-managed first-party sessions to kimi-2.7-coder', () => { + it('defaults noumena-managed first-party sessions to the GLM 5.2 premium tier', () => { process.env.CLAUDE_CODE_ENTRYPOINT = 'cli' process.env.USER_TYPE = 'test' delete process.env.NCODE_BUILD_MODE @@ -172,7 +177,7 @@ describe('model auth session gating', () => { }) withMockCurrentSession(session, () => { - expect(getDefaultMainLoopModelSetting()).toBe('kimi-2.7-coder') + expect(getDefaultMainLoopModelSetting()).toBe(GLM_5_2_MODEL) }) }) @@ -227,7 +232,42 @@ describe('model auth session gating', () => { withMockCurrentSession(session, () => { expect(isOpus1mMergeEnabled()).toBe(true) - expect(getDefaultMainLoopModelSetting()).toBe('kimi-2.7-coder') + expect(getDefaultMainLoopModelSetting()).toBe(GLM_5_2_MODEL) }) }) + + it('uses OpenAI-compatible defaults for OPENAI_API_KEY sessions', () => { + process.env.CLAUDE_CODE_ENTRYPOINT = 'cli' + process.env.USER_TYPE = 'test' + delete process.env.NCODE_BUILD_MODE + delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT + delete process.env.CLAUDE_CODE_USE_BEDROCK + delete process.env.CLAUDE_CODE_USE_VERTEX + delete process.env.CLAUDE_CODE_USE_FOUNDRY + process.env.OPENAI_API_KEY = 'openai-key' + process.env.OPENAI_MODEL = 'custom-openai-model' + + const session = makeSession({ + principalKind: 'api_key_user', + principalSource: 'direct_api_key_env', + sessionState: 'usable', + headersKind: 'none', + providerAuthKind: 'byok_static_env', + providerPlan: { + mode: 'byok_static_env', + source: 'direct_api_key_env', + staticKeyEnvVarName: 'OPENAI_API_KEY', + }, + hasUsableApiKey: true, + apiKey: 'openai-key', + rawApiKeySource: 'OPENAI_API_KEY', + }) + + withMockCurrentSession(session, () => { + expect(getDefaultOpusModel()).toBe('custom-openai-model') + expect(getDefaultFlashModel()).toBe('custom-openai-model') + expect(getDefaultMainLoopModelSetting()).toBe('custom-openai-model') + }) + }) + }) diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 5050248..f76c6a4 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -17,7 +17,11 @@ import { formatModelPricing, getOpus46CostTier } from '../modelCost.js' import { getCurrentSubscriptionSessionState } from '../subscriptionSession.js' import { getSettings_DEPRECATED } from '../settings/settings.js' import type { PermissionMode } from '../permissions/PermissionMode.js' -import { getAPIProvider } from './providers.js' +import { + getAPIProvider, + getOpenAICompatDefaultModel, + isOpenAICompatByokActive, +} from './providers.js' import { getAntModelOverrideConfig, resolveAntModel, @@ -39,7 +43,16 @@ export type ModelName = string export type ModelSetting = ModelName | ModelAlias | null function getConfiguredMainModelEnv(): string | undefined { - return process.env.NOUMENA_MODEL || process.env.ANTHROPIC_MODEL + if (isOpenAICompatByokActive()) { + return ( + process.env.OPENAI_MODEL || + process.env.NOUMENA_MODEL || + process.env.ANTHROPIC_MODEL + ) + } + return ( + process.env.NOUMENA_MODEL || process.env.ANTHROPIC_MODEL + ) } function getConfiguredSmallFastModelEnv(): string | undefined { @@ -146,6 +159,9 @@ export function getBestModel(): ModelName { // @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged). export function getDefaultOpusModel(): ModelName { + if (isOpenAICompatByokActive()) { + return getOpenAICompatDefaultModel() + } const configuredModel = getConfiguredDefaultOpusModelEnv() if (configuredModel) { return configuredModel @@ -161,6 +177,9 @@ export function getDefaultOpusModel(): ModelName { // @[MODEL LAUNCH]: Update the default Flash model (3P providers may lag so keep defaults unchanged). export function getDefaultFlashModel(): ModelName { + if (isOpenAICompatByokActive()) { + return getOpenAICompatDefaultModel() + } const configuredModel = getConfiguredDefaultFlashModelEnv() if (configuredModel) { return configuredModel @@ -177,6 +196,9 @@ export function getDefaultFlashModel(): ModelName { // @[MODEL LAUNCH]: Update the default Haiku model (3P providers may lag so keep defaults unchanged). export function getDefaultHaikuModel(): ModelName { + if (isOpenAICompatByokActive()) { + return getOpenAICompatDefaultModel() + } const configuredModel = getConfiguredDefaultHaikuModelEnv() if (configuredModel) { return configuredModel diff --git a/src/utils/model/modelOptions.auth.test.ts b/src/utils/model/modelOptions.auth.test.ts index 7731f48..cc3000f 100644 --- a/src/utils/model/modelOptions.auth.test.ts +++ b/src/utils/model/modelOptions.auth.test.ts @@ -3,6 +3,7 @@ import { getAuthRuntime } from '../../auth/runtime/AuthRuntime.js' import type { ResolvedAuthSession } from '../../auth/runtime/types.js' import { getDefaultOptionForUser, + getModelOptions, getMaxOpus46_1MOption, } from './modelOptions.js' import { parseUserSpecifiedModel } from './model.js' @@ -18,6 +19,8 @@ const originalBuildMode = process.env.NCODE_BUILD_MODE const originalUseBedrock = process.env.CLAUDE_CODE_USE_BEDROCK const originalUseVertex = process.env.CLAUDE_CODE_USE_VERTEX const originalUseFoundry = process.env.CLAUDE_CODE_USE_FOUNDRY +const originalOpenAIApiKey = process.env.OPENAI_API_KEY +const originalOpenAIModel = process.env.OPENAI_MODEL function restoreEnv(): void { if (originalEntryPoint === undefined) { @@ -50,6 +53,16 @@ function restoreEnv(): void { } else { process.env.CLAUDE_CODE_USE_FOUNDRY = originalUseFoundry } + if (originalOpenAIApiKey === undefined) { + delete process.env.OPENAI_API_KEY + } else { + process.env.OPENAI_API_KEY = originalOpenAIApiKey + } + if (originalOpenAIModel === undefined) { + delete process.env.OPENAI_MODEL + } else { + process.env.OPENAI_MODEL = originalOpenAIModel + } } function makeSession( @@ -223,6 +236,43 @@ describe('modelOptions auth gating', () => { }) }) + it('shows only OpenAI-compatible default options for OpenAI BYOK sessions', () => { + process.env.CLAUDE_CODE_ENTRYPOINT = 'cli' + process.env.USER_TYPE = 'test' + delete process.env.NCODE_BUILD_MODE + delete process.env.CLAUDE_CODE_USE_BEDROCK + delete process.env.CLAUDE_CODE_USE_VERTEX + delete process.env.CLAUDE_CODE_USE_FOUNDRY + process.env.OPENAI_API_KEY = 'openai-key' + process.env.OPENAI_MODEL = 'custom-openai-model' + + const session = makeSession({ + principalKind: 'api_key_user', + principalSource: 'direct_api_key_env', + sessionState: 'usable', + headersKind: 'none', + providerAuthKind: 'byok_static_env', + providerPlan: { + mode: 'byok_static_env', + source: 'direct_api_key_env', + staticKeyEnvVarName: 'OPENAI_API_KEY', + }, + hasUsableApiKey: true, + apiKey: 'openai-key', + rawApiKeySource: 'OPENAI_API_KEY', + }) + + withMockCurrentSession(session, () => { + expect(getDefaultOptionForUser().description).toContain( + 'custom-openai-model', + ) + expect(getModelOptions().map(option => option.value)).toEqual([ + null, + 'custom-openai-model', + ]) + }) + }) + it('only marks opus 1M as billed-as-extra-usage for oauth-backed first-party sessions', () => { process.env.CLAUDE_CODE_ENTRYPOINT = 'cli' process.env.USER_TYPE = 'test' diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 14f5a43..e3349b5 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -15,7 +15,12 @@ import { getNCodeManagedModelOptions, resolveNCodeManagedModel, } from './ncodeModels.js' -import { getAPIProvider, getNoumenaBaseUrl } from './providers.js' +import { + getAPIProvider, + getNoumenaBaseUrl, + getOpenAICompatDefaultModel, + isOpenAICompatByokActive, +} from './providers.js' import { isModelAllowed } from './modelAllowlist.js' import { getCanonicalName, @@ -50,6 +55,7 @@ function hasEnvValue(value: string | undefined): boolean { function isNCodeManagedFirstPartySurface(): boolean { return ( getAPIProvider() === 'firstParty' && + !isOpenAICompatByokActive() && (Boolean(getNoumenaBaseUrl()) || hasEnvValue(process.env.NOUMENA_MODEL) || hasEnvValue(process.env.NOUMENA_SMALL_FAST_MODEL) || @@ -60,6 +66,23 @@ function isNCodeManagedFirstPartySurface(): boolean { ) } +function getOpenAICompatByokModelOptions(): ModelOption[] { + const model = getOpenAICompatDefaultModel() + return dedupeModelOptions([ + { + value: null, + label: 'Default (OpenAI compatible)', + description: `Use the OpenAI-compatible model (currently ${model})`, + descriptionForModel: `OpenAI-compatible default model (currently ${model})`, + }, + { + value: model, + label: model, + description: 'OpenAI-compatible BYOK model', + }, + ]) +} + export function modelOptionsReferToSameModel( a: ModelSetting, b: ModelSetting, @@ -327,6 +350,10 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { ]) } + if (isOpenAICompatByokActive()) { + return getOpenAICompatByokModelOptions() + } + if (isNCodeManagedFirstPartySurface()) { return dedupeModelOptions([ getDefaultOptionForUser(fastMode), diff --git a/src/utils/model/providers.test.ts b/src/utils/model/providers.test.ts index c95f1b7..0c0ec5e 100644 --- a/src/utils/model/providers.test.ts +++ b/src/utils/model/providers.test.ts @@ -3,12 +3,18 @@ import { getAnthropicBaseUrl, getFirstPartyBaseUrlOverride, getNoumenaBaseUrl, + getOpenAICompatBaseUrl, + getOpenAICompatDefaultModel, + isOpenAICompatByokActive, isFirstPartyNoumenaBaseUrl, } from './providers.js' function resetEnv() { delete process.env.NOUMENA_BASE_URL delete process.env.ANTHROPIC_BASE_URL + delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_MODEL delete process.env.USER_TYPE } @@ -63,4 +69,26 @@ describe('providers', () => { process.env.ANTHROPIC_BASE_URL = 'https://api-staging.anthropic.com' expect(isFirstPartyNoumenaBaseUrl()).toBe(true) }) + + + it('activates OpenAI-compatible BYOK only for OPENAI_API_KEY', () => { + expect(isOpenAICompatByokActive()).toBe(false) + expect(getOpenAICompatBaseUrl()).toBeUndefined() + + process.env.OPENAI_API_KEY = 'openai-key' + + expect(isOpenAICompatByokActive()).toBe(true) + expect(getOpenAICompatBaseUrl()).toBe('https://api.openai.com') + expect(getOpenAICompatDefaultModel()).toBe('gpt-5.1-codex') + }) + + it('uses explicit OpenAI-compatible base URL and model overrides', () => { + process.env.OPENAI_API_KEY = 'openai-key' + process.env.OPENAI_BASE_URL = ' https://openrouter.ai/api/v1 ' + process.env.OPENAI_MODEL = ' openrouter/custom-model ' + + expect(getOpenAICompatBaseUrl()).toBe('https://openrouter.ai/api/v1') + expect(getOpenAICompatDefaultModel()).toBe('openrouter/custom-model') + }) + }) diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index 18ea7e3..4087784 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -1,7 +1,13 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js' +import { + getDirectApiKeyEnvVarName, + isOpenAIDirectApiKeyEnvVar, +} from '../authEnv.js' import { isEnvTruthy } from '../envUtils.js' export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry' +export const OPENAI_COMPAT_DEFAULT_BASE_URL = 'https://api.openai.com' +export const OPENAI_COMPAT_DEFAULT_MODEL = 'gpt-5.1-codex' const FIRST_PARTY_NOUMENA_HOSTS = [ 'api.noumena.com', @@ -9,17 +15,36 @@ const FIRST_PARTY_NOUMENA_HOSTS = [ ] const FIRST_PARTY_ANTHROPIC_HOSTS = ['api.anthropic.com'] -function normalizeBaseUrl(value: string | undefined): string | undefined { - const baseUrl = value?.trim() - return baseUrl && baseUrl.length > 0 ? baseUrl : undefined +function normalizeEnvValue(value: string | undefined): string | undefined { + const normalized = value?.trim() + return normalized && normalized.length > 0 ? normalized : undefined } export function getNoumenaBaseUrl(): string | undefined { - return normalizeBaseUrl(process.env.NOUMENA_BASE_URL) + return normalizeEnvValue(process.env.NOUMENA_BASE_URL) } export function getAnthropicBaseUrl(): string | undefined { - return normalizeBaseUrl(process.env.ANTHROPIC_BASE_URL) + return normalizeEnvValue(process.env.ANTHROPIC_BASE_URL) +} + +export function getOpenAIBaseUrl(): string | undefined { + return normalizeEnvValue(process.env.OPENAI_BASE_URL) +} + +export function isOpenAICompatByokActive(): boolean { + return isOpenAIDirectApiKeyEnvVar(getDirectApiKeyEnvVarName()) +} + +export function getOpenAICompatBaseUrl(): string | undefined { + if (!isOpenAICompatByokActive()) { + return undefined + } + return getOpenAIBaseUrl() ?? OPENAI_COMPAT_DEFAULT_BASE_URL +} + +export function getOpenAICompatDefaultModel(): string { + return normalizeEnvValue(process.env.OPENAI_MODEL) ?? OPENAI_COMPAT_DEFAULT_MODEL } export function getFirstPartyBaseUrlOverride(): string | undefined { diff --git a/src/utils/preflightChecks.test.ts b/src/utils/preflightChecks.test.ts index 9ad1bfc..e39e29d 100644 --- a/src/utils/preflightChecks.test.ts +++ b/src/utils/preflightChecks.test.ts @@ -21,9 +21,12 @@ describe('getPreflightEndpoints', () => { process.env.NOUMENA_PLATFORM_BASE_URL = 'https://platform-api.noumena.test/' process.env.NOUMENA_ISSUER_BASE_URL = 'https://issuer.noumena.test/' + // #11 changed preflight from reachability (/healthz + JWKS) to auth-flow + // validation (/v1/me + oauth/token), so we catch auth/config failures at + // startup rather than only server-down failures. expect(getPreflightEndpoints()).toEqual([ - 'https://platform-api.noumena.test/healthz', - 'https://issuer.noumena.test/.well-known/jwks.json', + 'https://platform-api.noumena.test/v1/me', + 'https://issuer.noumena.test/oauth/token', ]) }) }) diff --git a/src/utils/statusNoticeDefinitions.tsx b/src/utils/statusNoticeDefinitions.tsx index 022f577..eb25aee 100644 --- a/src/utils/statusNoticeDefinitions.tsx +++ b/src/utils/statusNoticeDefinitions.tsx @@ -54,7 +54,11 @@ function formatAuthTokenSourceLabel(source: string): string { } function getApiKeySourceResolution(source: string, directApiKeyEnvName: string): string { - if (source === 'NOUMENA_API_KEY' || source === 'ANTHROPIC_API_KEY') { + if ( + source === 'NOUMENA_API_KEY' || + source === 'ANTHROPIC_API_KEY' || + source === 'OPENAI_API_KEY' + ) { return `Unset the ${directApiKeyEnvName} environment variable.`; } @@ -118,6 +122,7 @@ export function getManagedKeyConflictSource( return session.rawApiKeySource === 'NOUMENA_API_KEY' || session.rawApiKeySource === 'ANTHROPIC_API_KEY' || + session.rawApiKeySource === 'OPENAI_API_KEY' || session.rawApiKeySource === 'apiKeyHelper' ? session.rawApiKeySource : null; diff --git a/src/utils/subprocessEnv.ts b/src/utils/subprocessEnv.ts index 4c39663..b851a96 100644 --- a/src/utils/subprocessEnv.ts +++ b/src/utils/subprocessEnv.ts @@ -16,6 +16,7 @@ const GHA_SUBPROCESS_SCRUB = [ // Anthropic auth — claude re-reads these per-request, subprocesses don't need them 'NOUMENA_API_KEY', 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_FOUNDRY_API_KEY',