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:
StanAIML
2026-07-20 10:02:00 +08:00
committed by GitHub
co-authored by Lookoff123
parent 5399a11d3c
commit 7674d4d73e
22 changed files with 885 additions and 91 deletions
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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')
+13 -13
View File
@@ -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',
+22 -22
View File
@@ -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}
+71
View File
@@ -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)
})
+97
View File
@@ -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(
{
+9 -2
View File
@@ -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
}
+16 -2
View File
@@ -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 ?? {}) : {}),
}
+4 -3
View File
@@ -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",
+1 -1
View File
@@ -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'],
])(
+1 -1
View File
@@ -63,7 +63,7 @@ export function matchHostnameAgainstRouteHosts(
})
}
function normalizeComparableBaseUrl(
export function normalizeComparableBaseUrl(
baseUrl?: string,
): string | null {
if (!baseUrl?.trim()) {
+78 -1
View File
@@ -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 -4
View File
@@ -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,
),
),
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 () => {
+323
View File
@@ -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',
+112 -8
View File
@@ -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 ||
+52 -6
View File
@@ -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)
+17 -5
View File
@@ -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'
+1 -1
View File
@@ -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',
)