mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
feat(aimlapi): provider foundation (1/5) — config, catalog, ambient-key gate (#1995)
* feat(aimlapi): provider foundation — config, catalog, ambient-key gate First layer of the AI/ML API onboarding split. Self-contained: config endpoints + partner-header resolution, gateway catalog entry, runtime metadata and artifact-generator wiring, and the regenerated integration manifest. Also lands the P1 security fix in providerProfiles: an ambient AIMLAPI key is only forwarded when the profile targets the canonical inference endpoint, so a proxy/staging profile can never leak the key elsewhere. No client/checkout/UI changes here — those stack in later PRs. * fix(aimlapi): harden credential gating and restore renamed-preset test path * fix(aimlapi): harden credential gating and finish renamed-preset path * fix(aimlapi): finish aimlapi.com rename in provider UI; gate generic proxy credential * fix(aimlapi): close remaining proxy credential and attribution leaks * fix(aimlapi): withhold ambient credentials and attribution from proxies * fix(aimlapi): withhold ambient credentials and attribution from proxies * fix(aimlapi): stop forcing profile credentials onto retargeted endpoints * fix(aimlapi): withhold ambient custom headers from proxy launches --------- Co-authored-by: Lookoff123 <bataryshkinairina@gmail.com>
This commit is contained in:
@@ -13,7 +13,7 @@ None. You don't need to visit <https://aimlapi.com> first — the guided top-up
|
||||
## Option 1 — Interactive (`/provider`)
|
||||
|
||||
1. Start OpenClaude and run `/provider`.
|
||||
2. Choose **AI/ML API**, then confirm the default model (Step 1 of 2).
|
||||
2. Choose **aimlapi.com**, then confirm the default model (Step 1 of 2).
|
||||
3. Step 2 of 2 — choose how to get an API key:
|
||||
- **Top up and get API key** — enter your AI/ML API email and password (an account is created automatically if you don't have one yet), pick a top-up amount ($20–$10,000) and payment method (card or crypto), complete payment in the browser, and OpenClaude saves the issued key for you.
|
||||
- **Enter existing API key** — paste a key you already have from the AI/ML API dashboard.
|
||||
@@ -59,7 +59,7 @@ export OPENAI_MODEL="gpt-4o"
|
||||
|
||||
## Verify
|
||||
|
||||
- `/status` shows **AI/ML API** as the active provider with the `https://api.aimlapi.com/v1` base URL.
|
||||
- `/status` shows **aimlapi.com** as the active provider with the `https://api.aimlapi.com/v1` base URL.
|
||||
- `/model` lists chat-capable models discovered from the catalog.
|
||||
- Send any prompt to confirm responses come back from the selected model.
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ test('third-party provider branch opens the first-run provider manager', async (
|
||||
expect(output).toContain('Set up provider')
|
||||
// Keep the sentinels near the top of the preset list so adding another
|
||||
// provider does not push the assertion outside the 13-row test frame.
|
||||
expect(output).toContain('AI/ML API')
|
||||
expect(output).toContain('aimlapi.com')
|
||||
expect(output).toContain('Anthropic')
|
||||
expect(output).toContain('Azure OpenAI')
|
||||
expect(output).toContain('DeepSeek')
|
||||
|
||||
@@ -123,8 +123,8 @@ async function waitForCondition(
|
||||
// canUseCodexOAuth === true (default in mocked tests).
|
||||
const PRESET_ORDER = [
|
||||
'Gitlawb Opengateway',
|
||||
'aimlapi.com',
|
||||
'Anthropic',
|
||||
'AI/ML API',
|
||||
'Alibaba Coding Plan (China)',
|
||||
'Alibaba Coding Plan',
|
||||
'Atlas Cloud',
|
||||
@@ -267,7 +267,7 @@ function mockProviderProfilesModule(options?: {
|
||||
if (preset === 'aimlapi') {
|
||||
return {
|
||||
provider: 'aimlapi',
|
||||
name: 'AI/ML API',
|
||||
name: 'aimlapi.com',
|
||||
baseUrl: 'https://api.aimlapi.com/v1',
|
||||
model: 'gpt-4o',
|
||||
apiKey: '',
|
||||
@@ -463,7 +463,7 @@ function mockProviderManagerDependencies(
|
||||
provisionAimlapiKey:
|
||||
options?.provisionAimlapiKey ??
|
||||
(async () => {
|
||||
throw new Error('Unexpected AI/ML API top-up in test')
|
||||
throw new Error('Unexpected aimlapi.com top-up in test')
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -921,7 +921,7 @@ test('ProviderManager saves OpenAI preset GPT-5 models with Responses API', asyn
|
||||
}
|
||||
})
|
||||
|
||||
test('ProviderManager saves AI/ML API preset with OpenAI-compatible defaults', async () => {
|
||||
test('ProviderManager saves aimlapi.com preset with OpenAI-compatible defaults', async () => {
|
||||
const addProviderProfile = mock((payload: any) => ({
|
||||
id: 'aimlapi_profile',
|
||||
...payload,
|
||||
@@ -945,14 +945,14 @@ test('ProviderManager saves AI/ML API preset with OpenAI-compatible defaults', a
|
||||
frame.includes('Choose provider preset'),
|
||||
)
|
||||
|
||||
await navigateToPreset(mounted.stdin, 'AI/ML API')
|
||||
await navigateToPreset(mounted.stdin, 'aimlapi.com')
|
||||
mounted.stdin.write('\r')
|
||||
const modelOutput = await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Create provider profile') &&
|
||||
frame.includes('Step 1 of 2: Default model'),
|
||||
)
|
||||
|
||||
expect(modelOutput).toContain('AI/ML API')
|
||||
expect(modelOutput).toContain('aimlapi.com')
|
||||
expect(modelOutput).toContain('gpt-4o')
|
||||
expect(modelOutput).not.toContain('Provider name')
|
||||
expect(modelOutput).not.toContain('Base URL')
|
||||
@@ -968,7 +968,7 @@ test('ProviderManager saves AI/ML API preset with OpenAI-compatible defaults', a
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Enter the API key for AI/ML API'),
|
||||
frame.includes('Enter the API key for aimlapi.com'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('aimlapi-test-key')
|
||||
@@ -979,7 +979,7 @@ test('ProviderManager saves AI/ML API preset with OpenAI-compatible defaults', a
|
||||
expect(addProviderProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'aimlapi',
|
||||
name: 'AI/ML API',
|
||||
name: 'aimlapi.com',
|
||||
baseUrl: 'https://api.aimlapi.com/v1',
|
||||
model: 'gpt-4o',
|
||||
apiKey: 'aimlapi-test-key',
|
||||
@@ -992,7 +992,7 @@ test('ProviderManager saves AI/ML API preset with OpenAI-compatible defaults', a
|
||||
}
|
||||
})
|
||||
|
||||
test('ProviderManager can top up AI/ML API and save the issued key', async () => {
|
||||
test('ProviderManager can top up aimlapi.com and save the issued key', async () => {
|
||||
delete process.env.AIMLAPI_EMAIL
|
||||
delete process.env.AIMLAPI_PASSWORD
|
||||
|
||||
@@ -1032,7 +1032,7 @@ test('ProviderManager can top up AI/ML API and save the issued key', async () =>
|
||||
frame.includes('Choose provider preset'),
|
||||
)
|
||||
|
||||
await navigateToPreset(mounted.stdin, 'AI/ML API')
|
||||
await navigateToPreset(mounted.stdin, 'aimlapi.com')
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Step 1 of 2: Default model'),
|
||||
@@ -1045,14 +1045,14 @@ test('ProviderManager can top up AI/ML API and save the issued key', async () =>
|
||||
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Enter your AI/ML API account email'),
|
||||
frame.includes('Enter your aimlapi.com account email'),
|
||||
)
|
||||
mounted.stdin.write('user@example.com')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Enter your AI/ML API password'),
|
||||
frame.includes('Enter your aimlapi.com password'),
|
||||
)
|
||||
mounted.stdin.write('secret-password')
|
||||
await Bun.sleep(25)
|
||||
@@ -1087,7 +1087,7 @@ test('ProviderManager can top up AI/ML API and save the issued key', async () =>
|
||||
expect(addProviderProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'aimlapi',
|
||||
name: 'AI/ML API',
|
||||
name: 'aimlapi.com',
|
||||
baseUrl: 'https://api.aimlapi.com/v1',
|
||||
model: 'gpt-4o',
|
||||
apiKey: 'aimlapi-issued-key',
|
||||
|
||||
@@ -2419,7 +2419,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
const amountUsd = aimlapiTopupAmountUsd.trim()
|
||||
const parsedAmountUsd = Number(amountUsd)
|
||||
if (!trimmedEmail) {
|
||||
setErrorMessage('AI/ML API email is required.')
|
||||
setErrorMessage('aimlapi.com email is required.')
|
||||
setScreen('aimlapi-topup-email')
|
||||
return
|
||||
}
|
||||
@@ -2429,17 +2429,17 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
return
|
||||
}
|
||||
if (Math.round(parsedAmountUsd * 100) < MIN_AMOUNT_USD_MINOR) {
|
||||
setErrorMessage(`Minimum AI/ML API top-up is $${MIN_AMOUNT_USD_MINOR / 100}.`)
|
||||
setErrorMessage(`Minimum aimlapi.com top-up is $${MIN_AMOUNT_USD_MINOR / 100}.`)
|
||||
setScreen('aimlapi-topup-amount')
|
||||
return
|
||||
}
|
||||
if (Math.round(parsedAmountUsd * 100) > MAX_AMOUNT_USD_MINOR) {
|
||||
setErrorMessage(`Maximum AI/ML API top-up is $${MAX_AMOUNT_USD_MINOR / 100}.`)
|
||||
setErrorMessage(`Maximum aimlapi.com top-up is $${MAX_AMOUNT_USD_MINOR / 100}.`)
|
||||
setScreen('aimlapi-topup-amount')
|
||||
return
|
||||
}
|
||||
if (!password) {
|
||||
setErrorMessage('AI/ML API password is required.')
|
||||
setErrorMessage('aimlapi.com password is required.')
|
||||
setScreen('aimlapi-topup-password')
|
||||
return
|
||||
}
|
||||
@@ -2479,7 +2479,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
setIsAimlapiTopupRunning(false)
|
||||
setErrorMessage(`Could not finish AI/ML API top-up: ${detail}`)
|
||||
setErrorMessage(`Could not finish aimlapi.com top-up: ${detail}`)
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -2491,7 +2491,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
Create provider profile
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Choose how to configure AI/ML API. Endpoint and model are already
|
||||
Choose how to configure aimlapi.com. Endpoint and model are already
|
||||
configured.
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
@@ -2509,7 +2509,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
{
|
||||
value: 'manual',
|
||||
label: 'Enter existing API key',
|
||||
description: 'Paste a key you already have from AI/ML API',
|
||||
description: 'Paste a key you already have from aimlapi.com',
|
||||
},
|
||||
]}
|
||||
onChange={(value: string) => {
|
||||
@@ -2548,10 +2548,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="remember" bold>
|
||||
AI/ML API top-up
|
||||
aimlapi.com top-up
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Enter your AI/ML API account email. The checkout flow will use it to
|
||||
Enter your aimlapi.com account email. The checkout flow will use it to
|
||||
register or sign in.
|
||||
</Text>
|
||||
<Text dimColor>Step 2 of 2: Top up account</Text>
|
||||
@@ -2563,7 +2563,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
onSubmit={value => {
|
||||
const email = value.trim()
|
||||
if (!email) {
|
||||
setErrorMessage('AI/ML API email is required.')
|
||||
setErrorMessage('aimlapi.com email is required.')
|
||||
return
|
||||
}
|
||||
setAimlapiTopupEmail(email)
|
||||
@@ -2591,7 +2591,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="remember" bold>
|
||||
AI/ML API top-up
|
||||
aimlapi.com top-up
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Choose a top-up amount in USD. Minimum is ${MIN_AMOUNT_USD_MINOR / 100}.
|
||||
@@ -2610,11 +2610,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
return
|
||||
}
|
||||
if (Math.round(parsedAmountUsd * 100) < MIN_AMOUNT_USD_MINOR) {
|
||||
setErrorMessage(`Minimum AI/ML API top-up is $${MIN_AMOUNT_USD_MINOR / 100}.`)
|
||||
setErrorMessage(`Minimum aimlapi.com top-up is $${MIN_AMOUNT_USD_MINOR / 100}.`)
|
||||
return
|
||||
}
|
||||
if (Math.round(parsedAmountUsd * 100) > MAX_AMOUNT_USD_MINOR) {
|
||||
setErrorMessage(`Maximum AI/ML API top-up is $${MAX_AMOUNT_USD_MINOR / 100}.`)
|
||||
setErrorMessage(`Maximum aimlapi.com top-up is $${MAX_AMOUNT_USD_MINOR / 100}.`)
|
||||
return
|
||||
}
|
||||
setAimlapiTopupAmountUsd(amountUsd)
|
||||
@@ -2641,10 +2641,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="remember" bold>
|
||||
AI/ML API top-up
|
||||
aimlapi.com top-up
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Enter your AI/ML API password. The CLI will open checkout and save the
|
||||
Enter your aimlapi.com password. The CLI will open checkout and save the
|
||||
issued API key after payment.
|
||||
</Text>
|
||||
<Text dimColor>Step 2 of 2: Top up account</Text>
|
||||
@@ -2655,7 +2655,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
onChange={setAimlapiTopupPassword}
|
||||
onSubmit={value => {
|
||||
if (!value) {
|
||||
setErrorMessage('AI/ML API password is required.')
|
||||
setErrorMessage('aimlapi.com password is required.')
|
||||
return
|
||||
}
|
||||
setAimlapiTopupPassword(value)
|
||||
@@ -2684,11 +2684,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="remember" bold>
|
||||
AI/ML API top-up
|
||||
aimlapi.com top-up
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Choose how to pay. The selected method decides which checkout invoice
|
||||
AI/ML API opens.
|
||||
aimlapi.com opens.
|
||||
</Text>
|
||||
<Text dimColor>Step 2 of 2: Payment method</Text>
|
||||
<Select
|
||||
@@ -2724,9 +2724,9 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
|
||||
function renderAimlapiTopupProgress(): React.ReactNode {
|
||||
const labels: Record<AimlapiTopupStatus, string> = {
|
||||
registering: 'Registering AI/ML API account...',
|
||||
registering: 'Registering aimlapi.com account...',
|
||||
registered: 'Account registered.',
|
||||
'signing-in': 'Signing in to AI/ML API...',
|
||||
'signing-in': 'Signing in to aimlapi.com...',
|
||||
'signed-in': 'Signed in.',
|
||||
'creating-session': 'Creating checkout session...',
|
||||
'opening-checkout': 'Opening checkout...',
|
||||
@@ -2735,12 +2735,12 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
}
|
||||
const status = aimlapiTopupStatus
|
||||
? labels[aimlapiTopupStatus]
|
||||
: 'Preparing AI/ML API top-up...'
|
||||
: 'Preparing aimlapi.com top-up...'
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="remember" bold>
|
||||
AI/ML API top-up
|
||||
aimlapi.com top-up
|
||||
</Text>
|
||||
<Text dimColor>{status}</Text>
|
||||
{aimlapiTopupDetail ? <Text>{aimlapiTopupDetail}</Text> : null}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
isCanonicalAimlapiInferenceBaseUrl,
|
||||
resolvePartnerId,
|
||||
resolveEndpoints,
|
||||
withResolvedPartnerHeader,
|
||||
} from './config.js'
|
||||
|
||||
const envNames = [
|
||||
'AIMLAPI_AUTH_URL',
|
||||
'AIMLAPI_APP_URL',
|
||||
'AIMLAPI_INFERENCE_URL',
|
||||
'AIMLAPI_PARTNER_ID',
|
||||
] as const
|
||||
const originalEnv = Object.fromEntries(envNames.map(name => [name, process.env[name]]))
|
||||
|
||||
// Clear ambient AIMLAPI overrides before every test so default/fallback
|
||||
// assertions never depend on the invoking environment; the runner's original
|
||||
// values are restored in teardown.
|
||||
beforeEach(() => {
|
||||
for (const name of envNames) delete process.env[name]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of envNames) {
|
||||
const value = originalEnv[name]
|
||||
if (value === undefined) delete process.env[name]
|
||||
else process.env[name] = value
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveEndpoints returns the production endpoints', () => {
|
||||
expect(resolveEndpoints()).toEqual({
|
||||
authBaseUrl: 'https://auth.aimlapi.com',
|
||||
appBaseUrl: 'https://app.aimlapi.com',
|
||||
inferenceBaseUrl: 'https://api.aimlapi.com/v1',
|
||||
})
|
||||
})
|
||||
|
||||
test('partner id override is shared with the inference header', () => {
|
||||
process.env.AIMLAPI_PARTNER_ID = 'part_override'
|
||||
expect(resolvePartnerId()).toBe('part_override')
|
||||
expect(
|
||||
withResolvedPartnerHeader({
|
||||
'x-aimlapi-partner-id': 'part_catalog',
|
||||
'X-Title': 'OpenClaude',
|
||||
}),
|
||||
).toEqual({
|
||||
'X-AIMLAPI-Partner-ID': 'part_override',
|
||||
'X-Title': 'OpenClaude',
|
||||
})
|
||||
})
|
||||
|
||||
test('canonical endpoint check excludes proxies and look-alike paths', () => {
|
||||
// Exactly the production endpoint, with at most one trailing slash.
|
||||
expect(isCanonicalAimlapiInferenceBaseUrl('https://api.aimlapi.com/v1')).toBe(true)
|
||||
expect(isCanonicalAimlapiInferenceBaseUrl('https://api.aimlapi.com/v1/')).toBe(true)
|
||||
// Host/protocol compare case-insensitively via the parsed origin.
|
||||
expect(isCanonicalAimlapiInferenceBaseUrl('https://API.AIMLAPI.COM/v1')).toBe(true)
|
||||
|
||||
// Distinct paths must NOT receive the ambient credential.
|
||||
expect(isCanonicalAimlapiInferenceBaseUrl('https://api.aimlapi.com/V1')).toBe(false)
|
||||
expect(isCanonicalAimlapiInferenceBaseUrl('https://api.aimlapi.com/v1////')).toBe(false)
|
||||
expect(isCanonicalAimlapiInferenceBaseUrl('https://api.aimlapi.com/v1/models')).toBe(false)
|
||||
// A different protocol/host is never canonical.
|
||||
expect(isCanonicalAimlapiInferenceBaseUrl('http://api.aimlapi.com/v1')).toBe(false)
|
||||
expect(isCanonicalAimlapiInferenceBaseUrl('https://proxy.example.test/v1')).toBe(false)
|
||||
// Garbage input fails closed.
|
||||
expect(isCanonicalAimlapiInferenceBaseUrl('not-a-url')).toBe(false)
|
||||
})
|
||||
@@ -34,6 +34,7 @@ const DEFAULT_ENDPOINTS: AimlapiEndpoints = {
|
||||
*/
|
||||
export const DEFAULT_PARTNER_ID = 'part_62yQoGYDq4Yqnrj2R1iGrDNJ'
|
||||
export const DEFAULT_PARTNER_NAME = 'Gitlawb'
|
||||
export const PARTNER_HEADER_NAME = 'X-AIMLAPI-Partner-ID'
|
||||
|
||||
/** Default model id written into the profile - override with `--model`. */
|
||||
export const DEFAULT_MODEL = 'gpt-4o'
|
||||
@@ -52,6 +53,102 @@ export function resolveEndpoints(): AimlapiEndpoints {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve checkout and inference attribution with one shared precedence. */
|
||||
export function resolvePartnerId(explicit?: string): string {
|
||||
return (
|
||||
explicit?.trim() ||
|
||||
process.env.AIMLAPI_PARTNER_ID?.trim() ||
|
||||
DEFAULT_PARTNER_ID
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a header copy with the effective partner id. Header matching is
|
||||
* case-insensitive so an override replaces the catalog spelling instead of
|
||||
* creating a duplicate header.
|
||||
*/
|
||||
export function withResolvedPartnerHeader(
|
||||
headers: Readonly<Record<string, string>>,
|
||||
explicit?: string,
|
||||
): Record<string, string> {
|
||||
const resolved: Record<string, string> = {}
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (name.trim().toLowerCase() === PARTNER_HEADER_NAME.toLowerCase()) continue
|
||||
resolved[name] = value
|
||||
}
|
||||
resolved[PARTNER_HEADER_NAME] = resolvePartnerId(explicit)
|
||||
return resolved
|
||||
}
|
||||
|
||||
function parseCanonicalUrl(
|
||||
value: string,
|
||||
): { origin: string; pathname: string } | null {
|
||||
try {
|
||||
const url = new URL(value.trim())
|
||||
// `origin` already lowercases protocol and host. Collapse only a single
|
||||
// trailing slash so `/v1` and `/v1/` match, while `/v1//`, `/V1`, or
|
||||
// `/v1/anything` stay distinct from the canonical `/v1` path.
|
||||
return { origin: url.origin, pathname: url.pathname.replace(/\/$/, '') }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog attribution and existing-key preflight are production-only. This
|
||||
* predicate gates ambient-credential forwarding, so it compares parsed origins
|
||||
* (host/protocol case-insensitive) and a case-sensitive path: a look-alike like
|
||||
* `/V1` or `/v1////` must NOT be treated as the canonical endpoint.
|
||||
*/
|
||||
export function isCanonicalAimlapiInferenceBaseUrl(value: string): boolean {
|
||||
const canonical = parseCanonicalUrl(DEFAULT_ENDPOINTS.inferenceBaseUrl)
|
||||
const candidate = parseCanonicalUrl(value)
|
||||
return (
|
||||
canonical !== null &&
|
||||
candidate !== null &&
|
||||
candidate.origin === canonical.origin &&
|
||||
candidate.pathname === canonical.pathname
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribution headers AI/ML API records for canonical `api.aimlapi.com`
|
||||
* traffic. They identify the partner and the referring integration, so they
|
||||
* belong to the canonical endpoint only — see `resolveAimlapiAttributionHeaders`.
|
||||
*/
|
||||
const CATALOG_ATTRIBUTION_HEADER_NAMES = new Set([
|
||||
PARTNER_HEADER_NAME.toLowerCase(),
|
||||
'x-aimlapi-integration-repo',
|
||||
'x-aimlapi-integration-version',
|
||||
'http-referer',
|
||||
'x-title',
|
||||
])
|
||||
|
||||
/**
|
||||
* Resolve the aimlapi catalog headers for an outbound request. On the canonical
|
||||
* inference endpoint the partner id is resolved and attribution is sent; on any
|
||||
* other base URL (a user-controlled proxy) every attribution header is stripped,
|
||||
* so a third-party host never receives OpenClaude's partner identity.
|
||||
*
|
||||
* Both the inference (openai shim) and the model-discovery request paths route
|
||||
* through here, so the two cannot drift apart. A missing base URL means the
|
||||
* caller falls back to the route default, which is canonical.
|
||||
*/
|
||||
export function resolveAimlapiAttributionHeaders(
|
||||
headers: Readonly<Record<string, string>>,
|
||||
baseUrl: string | undefined,
|
||||
): Record<string, string> {
|
||||
if (!baseUrl || isCanonicalAimlapiInferenceBaseUrl(baseUrl)) {
|
||||
return withResolvedPartnerHeader(headers)
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).filter(
|
||||
([name]) => !CATALOG_ATTRIBUTION_HEADER_NAMES.has(name.trim().toLowerCase()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the co-branded checkout return URLs the hosted payment page redirects
|
||||
* to after the user pays or cancels. Carrying `sessionToken` + `partnerCheckout=1`
|
||||
|
||||
@@ -69,6 +69,25 @@ describe('integration artifact generator', () => {
|
||||
await expect(generatedIntegrationArtifactsAreCurrent()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
test('pins aimlapi.com as the second provider preset', async () => {
|
||||
const { manifestContent } = splitGeneratedArtifacts(
|
||||
await generateIntegrationArtifacts(),
|
||||
)
|
||||
const orderedMatch = manifestContent.match(
|
||||
/export const ORDERED_PROVIDER_PRESETS = \[\n([\s\S]*?)\n\] as const/,
|
||||
)
|
||||
expect(orderedMatch).not.toBeNull()
|
||||
const orderedPresetIds = Array.from(
|
||||
orderedMatch![1]!.matchAll(/"([^"]+)"/g),
|
||||
match => match[1]!,
|
||||
)
|
||||
expect(orderedPresetIds.slice(0, 3)).toEqual([
|
||||
'gitlawb-opengateway',
|
||||
'aimlapi',
|
||||
'anthropic',
|
||||
])
|
||||
})
|
||||
|
||||
test('derives loader and preset manifest entries for a preset gateway from descriptor files', async () => {
|
||||
await withFixtureRepo(
|
||||
{
|
||||
|
||||
@@ -234,8 +234,8 @@ function compareProviderPresetEntries(
|
||||
return 0
|
||||
}
|
||||
|
||||
// Pin Gitlawb Opengateway first so the startup-default provider is also
|
||||
// the first guided setup option when users need to add an API key.
|
||||
// Keep the primary guided providers at the top of setup: Gitlawb
|
||||
// Opengateway first, aimlapi.com second, then the native Anthropic option.
|
||||
if (leftPreset === 'gitlawb-opengateway') {
|
||||
return -1
|
||||
}
|
||||
@@ -243,6 +243,13 @@ function compareProviderPresetEntries(
|
||||
return 1
|
||||
}
|
||||
|
||||
if (leftPreset === 'aimlapi') {
|
||||
return -1
|
||||
}
|
||||
if (rightPreset === 'aimlapi') {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (leftPreset === 'anthropic') {
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../utils/providerDiscovery.js'
|
||||
import { firstUsableCredential, hasInvalidCredentialPlaceholder } from '../services/api/credentialPool.js'
|
||||
import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js'
|
||||
import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js'
|
||||
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'
|
||||
|
||||
export type RouteDiscoveryResult = {
|
||||
@@ -176,14 +177,27 @@ function getRouteDiscoveryApiKey(
|
||||
|
||||
export function getRouteDiscoveryHeaders(
|
||||
routeId: string,
|
||||
options?: { headers?: Record<string, string> },
|
||||
options?: { baseUrl?: string; headers?: Record<string, string> },
|
||||
): Record<string, string> | undefined {
|
||||
const transportConfig = getRouteDescriptor(routeId)?.transportConfig
|
||||
const acceptsCallerHeaders =
|
||||
getRouteCatalog(routeId)?.discovery?.requiresAuth !== false
|
||||
const headers = {
|
||||
// Descriptor headers are attribution, not transport plumbing: an `aimlapi`
|
||||
// profile keeps its route id while pointing at a user-controlled proxy, so the
|
||||
// `/models` request must be filtered on the same canonical predicate the
|
||||
// inference shim uses (`resolveAimlapiAttributionHeaders`). Without this the
|
||||
// discovery path would hand the partner identity to an arbitrary host.
|
||||
const descriptorHeaders = {
|
||||
...(transportConfig?.headers ?? {}),
|
||||
...(transportConfig?.openaiShim?.headers ?? {}),
|
||||
}
|
||||
const headers = {
|
||||
...(routeId === 'aimlapi'
|
||||
? resolveAimlapiAttributionHeaders(
|
||||
descriptorHeaders,
|
||||
getRouteBaseUrl(routeId, options),
|
||||
)
|
||||
: descriptorHeaders),
|
||||
...(acceptsCallerHeaders ? (options?.headers ?? {}) : {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineGateway } from '../define.js'
|
||||
import { publicBuildVersion } from '../../utils/version.js'
|
||||
import { withResolvedPartnerHeader } from '../aimlapi/config.js'
|
||||
|
||||
const AIMLAPI_CHAT_MODEL_TYPES = new Set([
|
||||
'openai/chat-completions',
|
||||
@@ -57,7 +58,7 @@ function mapAimlapiModel(raw: unknown) {
|
||||
|
||||
export default defineGateway({
|
||||
id: 'aimlapi',
|
||||
label: 'AI/ML API',
|
||||
label: 'aimlapi.com',
|
||||
category: 'aggregating',
|
||||
defaultBaseUrl: 'https://api.aimlapi.com/v1',
|
||||
defaultModel: 'gpt-4o',
|
||||
@@ -73,7 +74,7 @@ export default defineGateway({
|
||||
transportConfig: {
|
||||
kind: 'openai-compatible',
|
||||
openaiShim: {
|
||||
headers: {
|
||||
headers: withResolvedPartnerHeader({
|
||||
'X-AIMLAPI-Partner-ID': 'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
|
||||
'X-AIMLAPI-Integration-Repo': 'Gitlawb/openclaude',
|
||||
'X-AIMLAPI-Integration-Version': publicBuildVersion,
|
||||
@@ -81,7 +82,7 @@ export default defineGateway({
|
||||
// (issue #835). `HTTP-Referer`/`X-Title` identify the referring app.
|
||||
'HTTP-Referer': 'OpenClaude',
|
||||
'X-Title': 'OpenClaude',
|
||||
},
|
||||
}),
|
||||
supportsAuthHeaders: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -34,22 +34,6 @@ export const PROVIDER_PRESET_MANIFEST = [
|
||||
"color": "success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"preset": "anthropic",
|
||||
"routeKind": "vendor",
|
||||
"routeId": "anthropic",
|
||||
"vendorId": "anthropic",
|
||||
"description": "Native Claude API (x-api-key auth)",
|
||||
"apiKeyEnvVars": [
|
||||
"ANTHROPIC_API_KEY"
|
||||
],
|
||||
"baseUrlEnvVars": [
|
||||
"ANTHROPIC_BASE_URL"
|
||||
],
|
||||
"modelEnvVars": [
|
||||
"ANTHROPIC_MODEL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"preset": "aimlapi",
|
||||
"routeKind": "gateway",
|
||||
@@ -68,6 +52,22 @@ export const PROVIDER_PRESET_MANIFEST = [
|
||||
"color": "success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"preset": "anthropic",
|
||||
"routeKind": "vendor",
|
||||
"routeId": "anthropic",
|
||||
"vendorId": "anthropic",
|
||||
"description": "Native Claude API (x-api-key auth)",
|
||||
"apiKeyEnvVars": [
|
||||
"ANTHROPIC_API_KEY"
|
||||
],
|
||||
"baseUrlEnvVars": [
|
||||
"ANTHROPIC_BASE_URL"
|
||||
],
|
||||
"modelEnvVars": [
|
||||
"ANTHROPIC_MODEL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"preset": "dashscope-cn",
|
||||
"routeKind": "gateway",
|
||||
@@ -508,8 +508,8 @@ export const PROVIDER_PRESET_MANIFEST = [
|
||||
export type ProviderPreset = (typeof PROVIDER_PRESET_MANIFEST)[number]['preset']
|
||||
export const ORDERED_PROVIDER_PRESETS = [
|
||||
"gitlawb-opengateway",
|
||||
"anthropic",
|
||||
"aimlapi",
|
||||
"anthropic",
|
||||
"dashscope-cn",
|
||||
"dashscope-intl",
|
||||
"atlas-cloud",
|
||||
|
||||
@@ -556,7 +556,7 @@ test.each([
|
||||
['OpenRouter', 'https://openrouter.ai/api/v1', 'openai/gpt-5-mini', 'openrouter'],
|
||||
['DeepSeek', 'https://api.deepseek.com/v1', 'deepseek-v4-pro', 'deepseek'],
|
||||
['Hicap', 'https://api.hicap.ai/v1', 'claude-opus-4.8', 'hicap'],
|
||||
['AI/ML API', 'https://api.aimlapi.com/v1', 'gpt-4o', 'aimlapi'],
|
||||
['aimlapi.com', 'https://api.aimlapi.com/v1', 'gpt-4o', 'aimlapi'],
|
||||
['Xiaomi MiMo', 'https://api.xiaomimimo.com/v1', 'mimo-v2.5-pro', 'xiaomi-mimo'],
|
||||
['Venice', 'https://api.venice.ai/api/v1', 'venice-uncensored', 'venice'],
|
||||
])(
|
||||
|
||||
@@ -63,7 +63,7 @@ export function matchHostnameAgainstRouteHosts(
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeComparableBaseUrl(
|
||||
export function normalizeComparableBaseUrl(
|
||||
baseUrl?: string,
|
||||
): string | null {
|
||||
if (!baseUrl?.trim()) {
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
resolveOpenAIShimRuntimeContext,
|
||||
} from '../integrations/runtimeMetadata'
|
||||
import { setCachedModels } from './discoveryCache'
|
||||
import { getDiscoveryCacheKey } from './discoveryService'
|
||||
import {
|
||||
getDiscoveryCacheKey,
|
||||
getRouteDiscoveryHeaders,
|
||||
} from './discoveryService'
|
||||
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
|
||||
@@ -162,6 +165,80 @@ describe('resolveModelRuntimeLimits', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIMLAPI runtime attribution', () => {
|
||||
it('uses the partner override only on the canonical endpoint', () => {
|
||||
const previous = process.env.AIMLAPI_PARTNER_ID
|
||||
process.env.AIMLAPI_PARTNER_ID = 'part_runtime_override'
|
||||
try {
|
||||
const canonical = resolveOpenAIShimRuntimeContext({
|
||||
activeProfileProvider: 'aimlapi',
|
||||
baseUrl: 'https://api.aimlapi.com/v1',
|
||||
model: 'gpt-4o',
|
||||
})
|
||||
expect(canonical.openaiShimConfig.headers?.['X-AIMLAPI-Partner-ID']).toBe(
|
||||
'part_runtime_override',
|
||||
)
|
||||
|
||||
const proxy = resolveOpenAIShimRuntimeContext({
|
||||
activeProfileProvider: 'aimlapi',
|
||||
baseUrl: 'https://proxy.example.test/v1',
|
||||
model: 'gpt-4o',
|
||||
})
|
||||
// Every catalog attribution header must be stripped on a proxy endpoint,
|
||||
// not just the partner id.
|
||||
expect(proxy.openaiShimConfig.headers?.['X-AIMLAPI-Partner-ID']).toBeUndefined()
|
||||
expect(proxy.openaiShimConfig.headers?.['X-AIMLAPI-Integration-Repo']).toBeUndefined()
|
||||
expect(proxy.openaiShimConfig.headers?.['X-AIMLAPI-Integration-Version']).toBeUndefined()
|
||||
expect(proxy.openaiShimConfig.headers?.['HTTP-Referer']).toBeUndefined()
|
||||
expect(proxy.openaiShimConfig.headers?.['X-Title']).toBeUndefined()
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AIMLAPI_PARTNER_ID
|
||||
else process.env.AIMLAPI_PARTNER_ID = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('strips attribution from model discovery on a proxy endpoint', () => {
|
||||
// Startup discovery runs with the profile's own base URL while the route id
|
||||
// stays `aimlapi`, so the `/models` request must be filtered on the same
|
||||
// canonical predicate the inference shim uses — otherwise the proxy still
|
||||
// receives the partner identity.
|
||||
const proxy = getRouteDiscoveryHeaders('aimlapi', {
|
||||
baseUrl: 'https://proxy.example.test/v1',
|
||||
})
|
||||
for (const name of [
|
||||
'X-AIMLAPI-Partner-ID',
|
||||
'X-AIMLAPI-Integration-Repo',
|
||||
'X-AIMLAPI-Integration-Version',
|
||||
'HTTP-Referer',
|
||||
'X-Title',
|
||||
]) {
|
||||
expect(proxy?.[name]).toBeUndefined()
|
||||
}
|
||||
|
||||
// The canonical assertions below compare against the built-in partner id,
|
||||
// so an ambient AIMLAPI_PARTNER_ID in the invoking shell would fail them.
|
||||
const previous = process.env.AIMLAPI_PARTNER_ID
|
||||
delete process.env.AIMLAPI_PARTNER_ID
|
||||
try {
|
||||
const canonical = getRouteDiscoveryHeaders('aimlapi', {
|
||||
baseUrl: 'https://api.aimlapi.com/v1',
|
||||
})
|
||||
expect(canonical?.['X-AIMLAPI-Partner-ID']).toBe(
|
||||
'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
|
||||
)
|
||||
expect(canonical?.['HTTP-Referer']).toBe('OpenClaude')
|
||||
|
||||
// A missing base URL falls back to the route default, which is canonical.
|
||||
expect(getRouteDiscoveryHeaders('aimlapi')?.['X-AIMLAPI-Partner-ID']).toBe(
|
||||
'part_62yQoGYDq4Yqnrj2R1iGrDNJ',
|
||||
)
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AIMLAPI_PARTNER_ID
|
||||
else process.env.AIMLAPI_PARTNER_ID = previous
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveOpenAIShimRuntimeContext - Z.A.I GLM-5.2', () => {
|
||||
it.each([
|
||||
'glm-5.2',
|
||||
|
||||
@@ -27,6 +27,20 @@ import {
|
||||
import { parseCustomHeadersEnv } from '../utils/providerCustomHeaders.js'
|
||||
import { firstUsableCredential } from '../services/api/credentialPool.js'
|
||||
import { ZAI_GLM_OPENAI_SHIM } from './transport/zaiGlmShim.js'
|
||||
import { resolveAimlapiAttributionHeaders } from './aimlapi/config.js'
|
||||
|
||||
function resolveRouteOpenAIShimConfig(
|
||||
routeId: string | null,
|
||||
baseUrl: string | undefined,
|
||||
config: OpenAIShimTransportConfig,
|
||||
): OpenAIShimTransportConfig {
|
||||
if (routeId !== 'aimlapi' || !config.headers) return config
|
||||
|
||||
return {
|
||||
...config,
|
||||
headers: resolveAimlapiAttributionHeaders(config.headers, baseUrl),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeModelApiName(
|
||||
value: string | undefined,
|
||||
@@ -281,6 +295,8 @@ export function resolveOpenAIShimRuntimeContext(options?: {
|
||||
routeId && routeId !== 'anthropic'
|
||||
? getRouteDescriptor(routeId)
|
||||
: null
|
||||
const effectiveBaseUrl =
|
||||
options?.baseUrl ?? runtimeEnv.OPENAI_BASE_URL ?? runtimeEnv.OPENAI_API_BASE
|
||||
const catalogEntry =
|
||||
descriptor && routeId
|
||||
? getCatalogEntryForModel(routeId, options?.model)
|
||||
@@ -296,10 +312,17 @@ export function resolveOpenAIShimRuntimeContext(options?: {
|
||||
routeId,
|
||||
descriptor,
|
||||
catalogEntry,
|
||||
openaiShimConfig: mergeOpenAIShimConfig(
|
||||
descriptor?.transportConfig.openaiShim,
|
||||
catalogEntry?.transportOverrides?.openaiShim,
|
||||
inferredConfig,
|
||||
// Sanitize AIMLAPI attribution headers AFTER merging every layer: a
|
||||
// catalog- or model-level `openaiShim.headers` override could otherwise
|
||||
// reintroduce the partner/attribution headers on a proxy endpoint.
|
||||
openaiShimConfig: resolveRouteOpenAIShimConfig(
|
||||
routeId,
|
||||
effectiveBaseUrl,
|
||||
mergeOpenAIShimConfig(
|
||||
descriptor?.transportConfig.openaiShim,
|
||||
catalogEntry?.transportOverrides?.openaiShim,
|
||||
inferredConfig,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ export async function fetchLocalOpenAIModelOptions(
|
||||
? parseCustomHeadersEnv(process.env.ANTHROPIC_CUSTOM_HEADERS)
|
||||
: undefined
|
||||
const fallbackHeaders = routeId
|
||||
? getRouteDiscoveryHeaders(routeId, { headers: discoveryHeaders })
|
||||
? getRouteDiscoveryHeaders(routeId, { baseUrl, headers: discoveryHeaders })
|
||||
: discoveryHeaders
|
||||
|
||||
const discoverModels = deps.discoverModelsForRoute ?? discoverModelsForRoute
|
||||
|
||||
@@ -118,7 +118,7 @@ test('detects AI/ML API from descriptor route metadata', async () => {
|
||||
|
||||
expect(
|
||||
getLocalOpenAICompatibleProviderLabel('https://api.aimlapi.com/v1'),
|
||||
).toBe('AI/ML API')
|
||||
).toBe('aimlapi.com')
|
||||
})
|
||||
|
||||
test('detects Z.AI from descriptor route metadata', async () => {
|
||||
|
||||
@@ -353,6 +353,329 @@ test('openai launch lets live base URL override persisted AIMLAPI route marker',
|
||||
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, undefined)
|
||||
})
|
||||
|
||||
test('openai launch withholds the ambient AIMLAPI key from a keyless proxy profile on restart', async () => {
|
||||
// A keyless saved aimlapi profile that points at a user-controlled proxy keeps
|
||||
// its route id after relaunch, but the canonical AIMLAPI credential must never
|
||||
// be copied into that proxy session.
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
AIMLAPI_API_KEY: 'ambient-aimlapi-key',
|
||||
},
|
||||
})
|
||||
|
||||
// Still recognized as the aimlapi route...
|
||||
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'aimlapi')
|
||||
// ...but the ambient canonical credential is withheld from the proxy host.
|
||||
assert.equal(env.AIMLAPI_API_KEY, undefined)
|
||||
|
||||
// The same profile on the canonical host DOES receive the ambient key.
|
||||
const canonical = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://api.aimlapi.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://api.aimlapi.com/v1',
|
||||
AIMLAPI_API_KEY: 'ambient-aimlapi-key',
|
||||
},
|
||||
})
|
||||
assert.equal(canonical.AIMLAPI_API_KEY, 'ambient-aimlapi-key')
|
||||
})
|
||||
|
||||
test('openai launch withholds the ambient generic OpenAI credential from a keyless proxy aimlapi profile', async () => {
|
||||
// The generic OPENAI_API_KEY / OPENAI_API_KEYS alias is the same exfiltration
|
||||
// path: a keyless retained aimlapi profile on a proxy must not receive the
|
||||
// ambient canonical credential in either form.
|
||||
for (const ambient of [
|
||||
{ OPENAI_API_KEY: 'ambient-openai-key' },
|
||||
{ OPENAI_API_KEYS: 'ambient-key-a,ambient-key-b' },
|
||||
]) {
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
...ambient,
|
||||
},
|
||||
})
|
||||
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'aimlapi')
|
||||
assert.equal(env.OPENAI_API_KEY, undefined)
|
||||
assert.equal(env.OPENAI_API_KEYS, undefined)
|
||||
}
|
||||
|
||||
// The same ambient credential IS applied on the canonical host.
|
||||
const canonical = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://api.aimlapi.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://api.aimlapi.com/v1',
|
||||
OPENAI_API_KEY: 'ambient-openai-key',
|
||||
},
|
||||
})
|
||||
assert.equal(canonical.OPENAI_API_KEY, 'ambient-openai-key')
|
||||
})
|
||||
|
||||
test('openai launch keeps a keyed proxy aimlapi profile own OpenAI credential', async () => {
|
||||
// A proxy profile that carries its own key still authenticates with it; only
|
||||
// the ambient credential is dropped, not the user-configured one.
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
OPENAI_API_KEY: 'profile-own-key',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_API_KEY: 'ambient-openai-key',
|
||||
},
|
||||
})
|
||||
assert.equal(env.OPENAI_API_KEY, 'profile-own-key')
|
||||
})
|
||||
|
||||
test('openai launch keeps the proxy aimlapi guard across equivalent base URL spellings', async () => {
|
||||
// The saved route identity is retained on a normalized comparison, so a shell
|
||||
// base URL that differs only by a trailing slash (or host casing) still names
|
||||
// the same proxy. A literal comparison would drop the route id here and let
|
||||
// the ambient canonical credential through.
|
||||
for (const shellBaseUrl of [
|
||||
'https://proxy.example.com/v1/',
|
||||
'https://PROXY.example.com/v1',
|
||||
]) {
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: shellBaseUrl,
|
||||
OPENAI_API_KEY: 'ambient-openai-key',
|
||||
AIMLAPI_API_KEY: 'ambient-aimlapi-key',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'aimlapi')
|
||||
assert.equal(env.OPENAI_API_KEY, undefined)
|
||||
assert.equal(env.AIMLAPI_API_KEY, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('openai launch does not let a distinct proxy target inherit the saved aimlapi identity', async () => {
|
||||
// Path case and query parameters name a distinct target on the same host. The
|
||||
// saved identity must NOT carry over to it, because that identity is what
|
||||
// mirrors the profile's dedicated AIMLAPI_API_KEY — a key configured for the
|
||||
// original tenant, not this one.
|
||||
for (const [persistedBaseUrl, shellBaseUrl] of [
|
||||
['https://proxy.example.com/tenantA/v1', 'https://proxy.example.com/tenanta/v1'],
|
||||
['https://proxy.example.com/v1', 'https://proxy.example.com/v1?tenant=other'],
|
||||
]) {
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: persistedBaseUrl,
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
OPENAI_API_KEY: 'profile-own-tenant-key',
|
||||
AIMLAPI_API_KEY: 'profile-own-tenant-key',
|
||||
OPENAI_AUTH_HEADER: 'X-Profile-Auth',
|
||||
OPENAI_AUTH_HEADER_VALUE: 'profile-own-tenant-secret',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: shellBaseUrl,
|
||||
OPENAI_API_KEY: 'user-supplied-key',
|
||||
OPENAI_AUTH_HEADER_VALUE: 'user-supplied-secret',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, undefined)
|
||||
// The dedicated aimlapi credential rides on the route identity, so a target
|
||||
// that cannot inherit the identity never receives it.
|
||||
assert.equal(env.AIMLAPI_API_KEY, undefined)
|
||||
// Without that identity the launch falls back to the route-agnostic
|
||||
// precedence: the credential the user supplied for THIS endpoint wins. The
|
||||
// profile's own key must not be forced in over it — that would both hand a
|
||||
// key to an endpoint it was never configured for and discard the user's.
|
||||
assert.equal(env.OPENAI_API_KEY, 'user-supplied-key')
|
||||
assert.equal(env.OPENAI_AUTH_HEADER_VALUE, 'user-supplied-secret')
|
||||
}
|
||||
})
|
||||
|
||||
test('openai launch withholds ambient custom headers from a keyless proxy aimlapi profile', async () => {
|
||||
// ANTHROPIC_CUSTOM_HEADERS reaches the proxy: client.ts merges it into the
|
||||
// defaultHeaders passed to the OpenAI shim client, and its filter only drops
|
||||
// the three standard auth header names — a custom-named secret rides through.
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
ANTHROPIC_CUSTOM_HEADERS: 'X-Proxy-Auth: ambient-canonical-secret',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'aimlapi')
|
||||
assert.equal(env.ANTHROPIC_CUSTOM_HEADERS, undefined)
|
||||
|
||||
// Headers the profile itself persisted are the user's own configuration for
|
||||
// that proxy, so they survive — and an ambient value cannot override them.
|
||||
const owned = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
ANTHROPIC_CUSTOM_HEADERS: 'X-Proxy-Auth: profile-own-proxy-secret',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
ANTHROPIC_CUSTOM_HEADERS: 'X-Proxy-Auth: ambient-canonical-secret',
|
||||
},
|
||||
})
|
||||
assert.equal(
|
||||
owned.ANTHROPIC_CUSTOM_HEADERS,
|
||||
'X-Proxy-Auth: profile-own-proxy-secret',
|
||||
)
|
||||
|
||||
// On the canonical endpoint the ambient value keeps its existing behaviour.
|
||||
const canonical = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://api.aimlapi.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://api.aimlapi.com/v1',
|
||||
ANTHROPIC_CUSTOM_HEADERS: 'X-Trace: ambient-value',
|
||||
},
|
||||
})
|
||||
assert.equal(canonical.ANTHROPIC_CUSTOM_HEADERS, 'X-Trace: ambient-value')
|
||||
})
|
||||
|
||||
test('openai launch withholds ambient credentials from a look-alike canonical path', async () => {
|
||||
// `/V1` resolves to the aimlapi route by host, so the launch carries the
|
||||
// identity — but the strict canonical predicate rejects the path, so it is
|
||||
// treated as a proxy and the ambient canonical credential is withheld.
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://api.aimlapi.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://api.aimlapi.com/V1',
|
||||
OPENAI_API_KEY: 'ambient-openai-key',
|
||||
AIMLAPI_API_KEY: 'ambient-aimlapi-key',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(env.OPENAI_BASE_URL, 'https://api.aimlapi.com/V1')
|
||||
assert.equal(env.OPENAI_API_KEY, undefined)
|
||||
assert.equal(env.AIMLAPI_API_KEY, undefined)
|
||||
})
|
||||
|
||||
test('openai launch withholds ambient custom auth from a keyless proxy aimlapi profile', async () => {
|
||||
// Custom authentication is a second credential channel: the shim sends
|
||||
// OPENAI_AUTH_HEADER_VALUE as the request credential whenever
|
||||
// OPENAI_AUTH_HEADER names a header. An ambient pair must not ride along into
|
||||
// a proxy session that the profile never configured for it.
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_AUTH_HEADER: 'X-Ambient-Auth',
|
||||
OPENAI_AUTH_SCHEME: 'raw',
|
||||
OPENAI_AUTH_HEADER_VALUE: 'ambient-canonical-secret',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(env.CLAUDE_CODE_PROVIDER_ROUTE_ID, 'aimlapi')
|
||||
assert.equal(env.OPENAI_AUTH_HEADER, undefined)
|
||||
assert.equal(env.OPENAI_AUTH_SCHEME, undefined)
|
||||
assert.equal(env.OPENAI_AUTH_HEADER_VALUE, undefined)
|
||||
|
||||
// The profile's OWN custom auth is still applied — the user configured it for
|
||||
// that proxy — and an ambient value must not override it.
|
||||
const owned = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
OPENAI_AUTH_HEADER: 'X-Proxy-Auth',
|
||||
OPENAI_AUTH_SCHEME: 'raw',
|
||||
OPENAI_AUTH_HEADER_VALUE: 'profile-own-proxy-secret',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://proxy.example.com/v1',
|
||||
OPENAI_AUTH_HEADER: 'X-Ambient-Auth',
|
||||
OPENAI_AUTH_HEADER_VALUE: 'ambient-canonical-secret',
|
||||
},
|
||||
})
|
||||
assert.equal(owned.OPENAI_AUTH_HEADER, 'X-Proxy-Auth')
|
||||
assert.equal(owned.OPENAI_AUTH_HEADER_VALUE, 'profile-own-proxy-secret')
|
||||
|
||||
// On the canonical endpoint ambient custom auth keeps its existing behaviour.
|
||||
const canonical = await buildLaunchEnv({
|
||||
profile: 'openai',
|
||||
persisted: profile('openai', {
|
||||
CLAUDE_CODE_PROVIDER_ROUTE_ID: 'aimlapi',
|
||||
OPENAI_BASE_URL: 'https://api.aimlapi.com/v1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
}),
|
||||
goal: 'coding',
|
||||
processEnv: {
|
||||
OPENAI_BASE_URL: 'https://api.aimlapi.com/v1',
|
||||
OPENAI_AUTH_HEADER: 'X-Ambient-Auth',
|
||||
OPENAI_AUTH_HEADER_VALUE: 'ambient-canonical-secret',
|
||||
},
|
||||
})
|
||||
assert.equal(canonical.OPENAI_AUTH_HEADER_VALUE, 'ambient-canonical-secret')
|
||||
})
|
||||
|
||||
test('xai launch uses descriptor defaults and persisted xAI key', async () => {
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'xai',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveProviderRequest,
|
||||
} from '../services/api/providerConfig.js'
|
||||
import { parseChatgptAccountId } from '../services/api/codexOAuthShared.js'
|
||||
import { isCanonicalAimlapiInferenceBaseUrl } from '../integrations/aimlapi/config.js'
|
||||
import { parseCredentialList } from '../services/api/credentialPool.js'
|
||||
import {
|
||||
getGoalDefaultOpenAIModel,
|
||||
@@ -1387,6 +1388,37 @@ export function selectAutoProfile(
|
||||
return recommendedOllamaModel ? 'ollama' : 'openai'
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint equivalence for deciding whether a launch may inherit a saved
|
||||
* profile's route identity — and with it that profile's dedicated credential.
|
||||
*
|
||||
* Scheme and host case, plus a single trailing slash, are pure spelling of the
|
||||
* same endpoint. Path case and query parameters are NOT: `/tenantA` and
|
||||
* `/tenanta`, or `?tenant=a` and `?tenant=b`, are distinct targets on the same
|
||||
* host, and letting one inherit the other's identity would hand it a credential
|
||||
* configured for somewhere else. Deliberately stricter than
|
||||
* `normalizeComparableBaseUrl`, which lowercases paths and drops queries for
|
||||
* route *lookup*, where a false match is harmless.
|
||||
*/
|
||||
function isSameConfiguredEndpoint(
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
): boolean {
|
||||
const normalize = (value: string | undefined): string | null => {
|
||||
if (!value?.trim()) return null
|
||||
try {
|
||||
// `origin` lowercases scheme and host; pathname and search keep their case.
|
||||
const url = new URL(value.trim())
|
||||
return `${url.origin}${url.pathname.replace(/\/$/, '')}${url.search}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedLeft = normalize(left)
|
||||
return normalizedLeft !== null && normalizedLeft === normalize(right)
|
||||
}
|
||||
|
||||
export async function buildLaunchEnv(options: {
|
||||
profile: ProviderProfile
|
||||
persisted: ProfileFile | null
|
||||
@@ -1943,11 +1975,18 @@ export async function buildLaunchEnv(options: {
|
||||
// unauthenticated.
|
||||
const resolvedOpenAIRouteId = resolveRouteIdFromBaseUrl(env.OPENAI_BASE_URL)
|
||||
const persistedOpenAIRouteId = persistedEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID?.trim()
|
||||
// Compare on endpoint equivalence, not the raw string: a shell
|
||||
// `OPENAI_BASE_URL` that differs from the saved one only by a trailing slash
|
||||
// (or scheme/host casing) still names the same endpoint. A literal comparison
|
||||
// would drop the saved route identity there, and with it the aimlapi proxy
|
||||
// guard below. Path case and query parameters are NOT spelling, though — a
|
||||
// different tenant path or query is a distinct target and must not inherit
|
||||
// the profile's identity, which is what carries its dedicated credential.
|
||||
const shouldUsePersistedOpenAIRouteId =
|
||||
!resolvedOpenAIRouteId &&
|
||||
!!persistedOpenAIRouteId &&
|
||||
!!persistedOpenAIBaseUrl &&
|
||||
env.OPENAI_BASE_URL === persistedOpenAIBaseUrl
|
||||
isSameConfiguredEndpoint(env.OPENAI_BASE_URL, persistedOpenAIBaseUrl)
|
||||
const effectiveOpenAIRouteId =
|
||||
resolvedOpenAIRouteId ||
|
||||
(shouldUsePersistedOpenAIRouteId ? persistedOpenAIRouteId : undefined)
|
||||
@@ -1958,6 +1997,50 @@ export async function buildLaunchEnv(options: {
|
||||
} else {
|
||||
delete env.CLAUDE_CODE_PROVIDER_ROUTE_ID
|
||||
}
|
||||
// A keyless retained aimlapi profile on a non-canonical (proxy) base URL must
|
||||
// not receive the ambient canonical credential via the generic OPENAI_API_KEY
|
||||
// /OPENAI_API_KEYS alias either (the generic selection above prefers the live
|
||||
// shell value). Re-source the generic credential from the profile's OWN
|
||||
// persisted env and drop a purely ambient one.
|
||||
// Scoped to a launch that actually carries the aimlapi identity. A profile
|
||||
// retargeted to an endpoint it was not saved for keeps no identity, so it is
|
||||
// handled by the route-agnostic precedence above rather than here: forcing the
|
||||
// profile's own credential in would both hand a key to an endpoint it was not
|
||||
// configured for and discard a credential the user supplied for that endpoint.
|
||||
const isNoncanonicalAimlapiLaunch =
|
||||
effectiveOpenAIRouteId === 'aimlapi' &&
|
||||
!!env.OPENAI_BASE_URL?.trim() &&
|
||||
!isCanonicalAimlapiInferenceBaseUrl(env.OPENAI_BASE_URL)
|
||||
if (isNoncanonicalAimlapiLaunch) {
|
||||
delete env.OPENAI_API_KEY
|
||||
delete env.OPENAI_API_KEYS
|
||||
const persistedCredential = resolveOpenAICredentialEnvSelection(persistedEnv)
|
||||
if (persistedCredential) {
|
||||
env[persistedCredential.envVar] = persistedCredential.value
|
||||
}
|
||||
// Custom authentication is a second credential channel, not just transport
|
||||
// metadata: the OpenAI shim sends OPENAI_AUTH_HEADER_VALUE as the request
|
||||
// credential whenever OPENAI_AUTH_HEADER names a header. The selections
|
||||
// above prefer the live shell value, so without this an ambient canonical
|
||||
// secret would still reach the proxy through custom auth. Re-source the
|
||||
// whole trio from the profile's OWN persisted env and drop ambient values;
|
||||
// the three are applied as a unit so a shell header name can never activate
|
||||
// a value the profile did not configure.
|
||||
delete env.OPENAI_AUTH_HEADER
|
||||
delete env.OPENAI_AUTH_SCHEME
|
||||
delete env.OPENAI_AUTH_HEADER_VALUE
|
||||
if (usePersistedOpenAIConfig) {
|
||||
if (persistedOpenAIAuthHeader) {
|
||||
env.OPENAI_AUTH_HEADER = persistedOpenAIAuthHeader
|
||||
}
|
||||
if (persistedOpenAIAuthScheme) {
|
||||
env.OPENAI_AUTH_SCHEME = persistedOpenAIAuthScheme
|
||||
}
|
||||
if (persistedOpenAIAuthHeaderValue) {
|
||||
env.OPENAI_AUTH_HEADER_VALUE = persistedOpenAIAuthHeaderValue
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const dedicatedKey of [
|
||||
'ATLAS_CLOUD_API_KEY',
|
||||
'NEARAI_API_KEY',
|
||||
@@ -1977,12 +2060,22 @@ export async function buildLaunchEnv(options: {
|
||||
if (dedicatedKey === 'NVIDIA_API_KEY' && effectiveOpenAIRouteId !== 'nvidia-nim') {
|
||||
continue
|
||||
}
|
||||
const dedicatedValue =
|
||||
(dedicatedKey === 'AIMLAPI_API_KEY' && openAICredential?.kind === 'usable'
|
||||
? sanitizeApiKey(openAICredential.value)
|
||||
: undefined) ||
|
||||
sanitizeApiKey(processEnv[dedicatedKey]) ||
|
||||
sanitizeApiKey(persistedEnv[dedicatedKey])
|
||||
// On a non-canonical (proxy) aimlapi base URL, never source AIMLAPI_API_KEY
|
||||
// from ambient/session credentials — that would leak the canonical AIMLAPI
|
||||
// key to a user-controlled proxy on restart. The profile's OWN persisted key
|
||||
// is still applied, since the user configured that key for that proxy.
|
||||
const aimlapiBaseUrl = env.OPENAI_BASE_URL?.trim()
|
||||
const withholdAmbientAimlapiKey =
|
||||
dedicatedKey === 'AIMLAPI_API_KEY' &&
|
||||
!!aimlapiBaseUrl &&
|
||||
!isCanonicalAimlapiInferenceBaseUrl(aimlapiBaseUrl)
|
||||
const dedicatedValue = withholdAmbientAimlapiKey
|
||||
? sanitizeApiKey(persistedEnv[dedicatedKey])
|
||||
: (dedicatedKey === 'AIMLAPI_API_KEY' && openAICredential?.kind === 'usable'
|
||||
? sanitizeApiKey(openAICredential.value)
|
||||
: undefined) ||
|
||||
sanitizeApiKey(processEnv[dedicatedKey]) ||
|
||||
sanitizeApiKey(persistedEnv[dedicatedKey])
|
||||
if (dedicatedValue) {
|
||||
env[dedicatedKey] = dedicatedValue
|
||||
}
|
||||
@@ -1993,9 +2086,20 @@ export async function buildLaunchEnv(options: {
|
||||
env.NVIDIA_NIM = nvidiaNimFlag
|
||||
}
|
||||
}
|
||||
const customHeaders = shellCustomHeaders || persistedCustomHeaders
|
||||
// ANTHROPIC_CUSTOM_HEADERS is a third credential channel: client.ts parses it
|
||||
// and merges the result into the defaultHeaders it hands to the OpenAI shim
|
||||
// client, and its own filter only drops `authorization`, `x-api-key` and
|
||||
// `api-key` — a custom-named header such as `X-Proxy-Auth: <secret>` survives
|
||||
// and is sent on every request. So an ambient value must be withheld from a
|
||||
// non-canonical aimlapi launch exactly like the API key and the custom-auth
|
||||
// trio; only headers the profile itself persisted are restored.
|
||||
const customHeaders = isNoncanonicalAimlapiLaunch
|
||||
? persistedCustomHeaders
|
||||
: shellCustomHeaders || persistedCustomHeaders
|
||||
if (customHeaders) {
|
||||
env.ANTHROPIC_CUSTOM_HEADERS = customHeaders
|
||||
} else {
|
||||
delete env.ANTHROPIC_CUSTOM_HEADERS
|
||||
}
|
||||
const contextWindows =
|
||||
processEnv.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS ||
|
||||
|
||||
@@ -1029,7 +1029,7 @@ describe('applyProviderProfileToProcessEnv', () => {
|
||||
expect(getFreshAPIProvider()).toBe('openai')
|
||||
}, 20_000)
|
||||
|
||||
test('keyless custom AIMLAPI profile preserves route identity with ambient OpenAI key', async () => {
|
||||
test('keyless custom AIMLAPI profile preserves route identity without forwarding the ambient key', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
process.env.OPENAI_API_KEY = 'ambient-openai-key'
|
||||
@@ -1045,8 +1045,52 @@ describe('applyProviderProfileToProcessEnv', () => {
|
||||
|
||||
expect(process.env.OPENAI_BASE_URL).toBe('https://proxy.example.com/v1')
|
||||
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
|
||||
expect(process.env.OPENAI_API_KEY).toBe('ambient-openai-key')
|
||||
expect(process.env.AIMLAPI_API_KEY).toBe('ambient-openai-key')
|
||||
// The base URL is a user-controlled proxy, not the canonical inference
|
||||
// host, so the canonical AIMLAPI credential must not be forwarded to it.
|
||||
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
expect(process.env.AIMLAPI_API_KEY).toBeUndefined()
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('aimlapi')
|
||||
}, 20_000)
|
||||
|
||||
test('keyless AIMLAPI profile resolves AIMLAPI_API_KEY without persisting it', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
process.env.AIMLAPI_API_KEY = 'ambient-aimlapi-key'
|
||||
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildProfile({
|
||||
name: 'AI/ML API',
|
||||
provider: 'aimlapi',
|
||||
baseUrl: 'https://api.aimlapi.com/v1',
|
||||
model: 'gpt-4o',
|
||||
apiKey: undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(process.env.OPENAI_API_KEY).toBe('ambient-aimlapi-key')
|
||||
expect(process.env.AIMLAPI_API_KEY).toBe('ambient-aimlapi-key')
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('aimlapi')
|
||||
}, 20_000)
|
||||
|
||||
test('keyless AIMLAPI profile without a base URL resolves the ambient key as canonical', async () => {
|
||||
const { applyProviderProfileToProcessEnv } =
|
||||
await importFreshProviderProfileModules()
|
||||
process.env.AIMLAPI_API_KEY = 'ambient-aimlapi-key'
|
||||
|
||||
// A missing base URL resolves to the canonical aimlapi default; the guard
|
||||
// must treat it as canonical rather than crash on undefined.trim().
|
||||
applyProviderProfileToProcessEnv(
|
||||
buildProfile({
|
||||
name: 'AI/ML API',
|
||||
provider: 'aimlapi',
|
||||
baseUrl: undefined,
|
||||
model: 'gpt-4o',
|
||||
apiKey: undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(process.env.AIMLAPI_API_KEY).toBe('ambient-aimlapi-key')
|
||||
expect(process.env.OPENAI_API_KEY).toBe('ambient-aimlapi-key')
|
||||
expect(process.env.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('aimlapi')
|
||||
}, 20_000)
|
||||
|
||||
@@ -2422,7 +2466,7 @@ describe('getProviderPresetDefaults', () => {
|
||||
const defaults = getProviderPresetDefaults('aimlapi')
|
||||
|
||||
expect(defaults.provider).toBe('aimlapi')
|
||||
expect(defaults.name).toBe('AI/ML API')
|
||||
expect(defaults.name).toBe('aimlapi.com')
|
||||
expect(defaults.baseUrl).toBe('https://api.aimlapi.com/v1')
|
||||
expect(defaults.model).toBe('gpt-4o')
|
||||
expect(defaults.apiKey).toBe('aimlapi-live-key')
|
||||
@@ -3129,7 +3173,7 @@ describe('setActiveProviderProfile', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('keyless custom (proxy) AI/ML API profiles preserve AIMLAPI route identity', async () => {
|
||||
test('keyless custom (proxy) AI/ML API profiles keep route identity but withhold the ambient key', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
|
||||
process.chdir(tempDir)
|
||||
@@ -3175,7 +3219,9 @@ describe('setActiveProviderProfile', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(startupEnv.AIMLAPI_API_KEY).toBe('ambient-aimlapi-key')
|
||||
// Route identity is preserved, but the ambient canonical AIMLAPI key must
|
||||
// NOT be forwarded to a user-controlled proxy host.
|
||||
expect(startupEnv.AIMLAPI_API_KEY).toBeUndefined()
|
||||
expect(startupEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID).toBe('aimlapi')
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type ProfileEnv,
|
||||
type ProviderProfile as ProviderProfileStartup,
|
||||
} from './providerProfile.js'
|
||||
import { isCanonicalAimlapiInferenceBaseUrl } from '../integrations/aimlapi/config.js'
|
||||
import { refreshStartupDiscoveryForRoute } from '../integrations/discoveryService.js'
|
||||
import {
|
||||
getCatalogEntriesForRoute,
|
||||
@@ -1007,12 +1008,23 @@ export function applyProviderProfileToProcessEnv(
|
||||
}
|
||||
}
|
||||
if (isAimlapiProfile) {
|
||||
const ambientOpenAIKey = trimOrUndefined(process.env.OPENAI_API_KEY)
|
||||
openAIProfileEnv.CLAUDE_CODE_PROVIDER_ROUTE_ID = 'aimlapi'
|
||||
openAIProfileEnv.OPENAI_API_KEY =
|
||||
openAIProfileEnv.OPENAI_API_KEY ?? ambientOpenAIKey
|
||||
openAIProfileEnv.AIMLAPI_API_KEY =
|
||||
openAIProfileEnv.AIMLAPI_API_KEY ?? ambientOpenAIKey
|
||||
// The ambient AIMLAPI_API_KEY is the canonical aimlapi.com credential.
|
||||
// Only forward it when the profile targets the canonical inference host;
|
||||
// a keyless `aimlapi` profile can point at a user-controlled proxy, and
|
||||
// injecting the credential there would leak it. This mirrors the
|
||||
// guided-flow validation, which also gates on the canonical URL. A missing
|
||||
// base URL resolves to the aimlapi default (which is canonical), so treat
|
||||
// it as canonical rather than passing undefined into the string helper.
|
||||
if (!profile.baseUrl || isCanonicalAimlapiInferenceBaseUrl(profile.baseUrl)) {
|
||||
const ambientAimlapiKey =
|
||||
trimOrUndefined(process.env.AIMLAPI_API_KEY) ??
|
||||
trimOrUndefined(process.env.OPENAI_API_KEY)
|
||||
openAIProfileEnv.OPENAI_API_KEY =
|
||||
openAIProfileEnv.OPENAI_API_KEY ?? ambientAimlapiKey
|
||||
openAIProfileEnv.AIMLAPI_API_KEY =
|
||||
openAIProfileEnv.AIMLAPI_API_KEY ?? ambientAimlapiKey
|
||||
}
|
||||
}
|
||||
if (route.gatewayId === 'nvidia-nim') {
|
||||
openAIProfileEnv.NVIDIA_NIM = '1'
|
||||
|
||||
@@ -143,7 +143,7 @@ test('buildAPIProviderProperties uses the resolved route label for AI/ML API ses
|
||||
process.env.OPENAI_BASE_URL = 'https://api.aimlapi.com/v1'
|
||||
process.env.OPENAI_MODEL = 'gpt-4o'
|
||||
|
||||
expect(await readPropertyValue('Provider route', 'openai')).toBe('AI/ML API')
|
||||
expect(await readPropertyValue('Provider route', 'openai')).toBe('aimlapi.com')
|
||||
expect(await readPropertyValue('OpenAI base URL', 'openai')).toBe(
|
||||
'https://api.aimlapi.com/v1',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user