Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion OSS_BUILD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions src/auth/capabilities/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,13 @@ export function persistManagedRemoteBootstrapFailure(
export async function resolveManagedRemoteCapability(
options: ResolveManagedRemoteCapabilityOptions = {},
): Promise<ManagedRemoteCapability> {
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,
)
Expand Down Expand Up @@ -191,7 +195,11 @@ export async function resolveManagedRemoteCapability(
export async function resolveManagedRemoteBootstrapCapability(
options: ResolveManagedRemoteCapabilityOptions = {},
): Promise<ManagedRemoteCapability> {
const initialSession = getAuthRuntime().getCurrentSession()
const activeSession = getAuthRuntime().getCurrentSession()
const initialSession =
activeSession.principalSource === 'managed_oauth'
? activeSession
: (getAuthRuntime().getCurrentManagedSession() ?? activeSession)
const accessTokenOverride = normalizeAccessTokenOverride(
options.accessTokenOverride,
)
Expand Down Expand Up @@ -235,6 +243,7 @@ export async function resolveManagedRemoteRuntimeAuth(
const runtimeAuth: ManagedRemoteRuntimeAuth = {
getAccessToken: () =>
accessTokenOverride ??
getAuthRuntime().getCurrentManagedSession()?.accessToken ??
getAuthRuntime().getCurrentSession().accessToken ??
undefined,
onAuth401: staleAccessToken =>
Expand Down Expand Up @@ -288,16 +297,21 @@ export async function refreshManagedRemoteRuntimeAccessToken(
export async function resolveManagedRemoteRuntimeLease(
options: ResolveManagedRemoteCapabilityOptions = {},
): Promise<ManagedRemoteRuntimeLease> {
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,
}),
}
}
Expand Down
84 changes: 80 additions & 4 deletions src/auth/runtime/AuthRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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'
Expand Down
22 changes: 16 additions & 6 deletions src/auth/runtime/AuthRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
}
Expand All @@ -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'
Expand All @@ -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'
}
Expand All @@ -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'
}
Expand Down Expand Up @@ -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'
Expand Down
4 changes: 3 additions & 1 deletion src/auth/runtime/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
32 changes: 20 additions & 12 deletions src/capabilities/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -29,37 +34,40 @@ 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'
}
} catch {
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading