Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion apps/daemon/src/connectionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
isUnsupportedMaxTokensError,
} from './integrations/openai-chat-token-params.js';
import { aihubmixHeaders } from './integrations/aihubmix.js';
import { aimlapiHeaders } from './integrations/aimlapi.js';
import type { AgentCliEnvPrefs } from './app-config.js';
import type { RuntimeAgentDef } from './runtimes/types.js';
import { preparePromptFileForAgent, type PreparedPromptFile } from './runtimes/prompt-file.js';
Expand Down Expand Up @@ -945,7 +946,13 @@ function inspectProviderCompletion(
const obj = data && typeof data === 'object' ? data as Record<string, unknown> : null;
if (!obj) return { valid: false };

if (protocol === 'openai' || protocol === 'azure' || protocol === 'senseaudio' || protocol === 'aihubmix') {
if (
protocol === 'openai' ||
protocol === 'azure' ||
protocol === 'senseaudio' ||
protocol === 'aihubmix' ||
protocol === 'aimlapi'
) {
const responseModel = typeof obj.model === 'string' ? obj.model : '';
if (
// AIHubMix is omitted from the strict response-model check (like Azure):
Expand Down Expand Up @@ -1421,6 +1428,24 @@ function buildProviderCall(input: ProviderTestRequest): ProviderCallShape {
return '';
},
};
case 'aimlapi':
// aimlapi.com is wire-compatible with OpenAI but carries the attribution
// pair on every request (see aimlapiHeaders) — the smoke test included,
// so a key check is attributed like any other call.
return {
url: appendVersionedApiPath(baseUrl, '/chat/completions'),
headers: {
'content-type': 'application/json',
...aimlapiHeaders(apiKey),
},
body: {
model,
...buildOpenAIChatTokenParam(model, PROVIDER_MAX_TOKENS),
messages: [{ role: 'user', content: SMOKE_PROMPT }],
stream: false,
},
extractText: extractOpenAIMessageText,
};
case 'aihubmix':
// AIHubMix is wire-compatible with OpenAI but carries the fixed APP-Code
// attribution header on every request (see aihubmixHeaders). Same body /
Expand Down
83 changes: 83 additions & 0 deletions apps/daemon/src/integrations/aimlapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// aimlapi.com BYOK provider — shared identity + outbound header helper.
//
// aimlapi.com (https://aimlapi.com) is an OpenAI-wire-compatible aggregator:
// a single API key fronts ~900 models from many creators, routed by model name
// on the upstream side. Because the wire shape is identical to OpenAI's, the
// chat proxy, connection test and model discovery all reuse the OpenAI call
// shape — the ONLY thing that differs is the outbound headers, which is why
// every outbound call point funnels through `aimlapiHeaders()` rather than
// hand-building `Authorization` inline.
//
// The distinctive aimlapi.com detail is the attribution pair: aimlapi.com
// expects `X-AIMLAPI-Source` and `X-AIMLAPI-Partner-ID` on EVERY request it
// serves — inference, catalog and key checks alike, not just sign-up. Injecting
// them in one helper keeps the invariant "every aimlapi.com request carries our
// attribution" enforceable in one place instead of being re-derived at each
// call site; the aggregator's rebate accounting keys off exactly this, and a
// missing header means the request serves fine but is silently untagged.

/**
* Provisioned partner for this integration. Valid on both aimlapi.com's staging
* and production backends, so it ships compiled in and one build works against
* either — only the base URL differs. Overridable via `AIMLAPI_PARTNER_ID` for
* a staging-only test id.
*/
export const AIMLAPI_PARTNER_ID = 'part_9TWZWFsyMyNrBDEENq5JaU0r';

/**
* `<channel>/<client>` — the channel is a small closed set (agent|mcp|web) and
* the client is this integration's registry slug.
*/
export const AIMLAPI_SOURCE = 'agent/open-design';

/**
* Default base URL the daemon assumes when the BYOK form leaves the field
* blank. Kept here as the single source of truth so the chat proxy, model
* discovery and connection test all default to the same origin.
*
* Note the `/v1`: aimlapi.com's OpenAI-compatible surface lives there, while
* `/v2` on the same host is billing/usage only and 404s for chat completions.
*/
export const AIMLAPI_DEFAULT_BASE_URL = 'https://api.aimlapi.com/v1';

function partnerId(): string {
return (process.env.AIMLAPI_PARTNER_ID || '').trim() || AIMLAPI_PARTNER_ID;
}

/**
* The attribution pair on its own (no auth). For any route that carries its own
* auth header — spread this alongside it so every aimlapi.com request, whatever
* the wire protocol, still carries attribution.
*/
export function aimlapiAttributionHeaders(): Record<string, string> {
return {
'X-AIMLAPI-Source': AIMLAPI_SOURCE,
'X-AIMLAPI-Partner-ID': partnerId(),
};
}

/**
* Build the outbound header set for an aimlapi.com request: Bearer auth plus
* the attribution pair. Callers spread the result into their `fetch` headers
* and may add `content-type` etc. on top.
*/
export function aimlapiHeaders(apiKey: string): Record<string, string> {
return {
authorization: `Bearer ${apiKey}`,
...aimlapiAttributionHeaders(),
};
}

/**
* Origin of a configured base URL, for callers that need to reach a sibling
* path on the same host. Falls back to the default origin when the configured
* value is unusable, so a malformed setting degrades instead of throwing.
*/
export function aimlapiOriginFromBase(baseUrl: string | undefined | null): string {
const candidate = (baseUrl || '').trim() || AIMLAPI_DEFAULT_BASE_URL;
try {
return new URL(candidate).origin;
} catch {
return new URL(AIMLAPI_DEFAULT_BASE_URL).origin;
}
}
13 changes: 11 additions & 2 deletions apps/daemon/src/integrations/provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { isLoopbackApiHost } from '@open-design/contracts/api/connectionTest';
import { redactSecrets, validateUserProviderBaseUrl } from '../connectionTest.js';
import { googleProviderModelsUrl, normalizeGoogleModelId } from './google-models.js';
import { aihubmixHeaders, aihubmixCatalogUrl, parseAIHubMixCatalog } from './aihubmix.js';
import { aimlapiHeaders } from './aimlapi.js';

type ProviderModelsInput = ProviderModelsRequest & {
signal?: AbortSignal;
Expand Down Expand Up @@ -241,7 +242,7 @@ function providerModelsUrl(protocol: ConnectionTestProtocol, baseUrl: string, ap
// (GET /api/v1/models?type=llm), not the OpenAI /v1/models route.
return aihubmixCatalogUrl(baseUrl, 'llm');
}
if (protocol === 'openai' || protocol === 'senseaudio') {
if (protocol === 'openai' || protocol === 'senseaudio' || protocol === 'aimlapi') {
return appendVersionedApiPath(baseUrl, '/models');
}
if (protocol === 'anthropic') {
Expand All @@ -262,6 +263,12 @@ function providerModelsHeaders(
if (protocol === 'openai' || protocol === 'senseaudio') {
return { authorization: `Bearer ${apiKey}` };
}
if (protocol === 'aimlapi') {
// Carries the X-AIMLAPI-Source/Partner-ID attribution pair alongside Bearer
// auth — see aimlapiHeaders() for why every aimlapi.com call funnels through
// this helper instead of hand-building the Authorization header.
return aimlapiHeaders(apiKey);
}
if (protocol === 'aihubmix') {
// The catalogue is public — only attach Bearer auth (+ APP-Code) when the
// user actually supplied a key. An empty `Bearer ` would be rejected by
Expand All @@ -285,7 +292,9 @@ function extractModels(protocol: ConnectionTestProtocol, data: unknown): Provide
// (e.g. gpt-image-2 → "image_generation,llm") would otherwise leak in. Those
// belong to the dedicated image/video/audio pickers.
if (protocol === 'aihubmix') return parseAIHubMixCatalog(data, { chatOnly: true });
if (protocol === 'openai' || protocol === 'senseaudio') return extractOpenAiModels(data);
if (protocol === 'openai' || protocol === 'senseaudio' || protocol === 'aimlapi') {
return extractOpenAiModels(data);
}
if (protocol === 'anthropic') return extractAnthropicModels(data);
if (protocol === 'google') return extractGoogleModels(data);
return [];
Expand Down
26 changes: 26 additions & 0 deletions apps/daemon/src/memory-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ import {
} from './memory-extractions.js';
import { resolveProviderConfig } from './media/config.js';
import { AIHUBMIX_APP_CODE } from './integrations/aihubmix.js';
import {
AIMLAPI_DEFAULT_BASE_URL,
aimlapiAttributionHeaders,
} from './integrations/aimlapi.js';
import { spawn } from 'node:child_process';
import os from 'node:os';
import { createHash } from 'node:crypto';
Expand Down Expand Up @@ -194,6 +198,17 @@ const PROVIDER_DEFAULTS = {
model: 'gpt-4o-mini',
baseUrl: 'https://aihubmix.com/v1',
},
// aimlapi.com is OpenAI-wire-compatible, so the extractor falls through to
// callOpenAI with this base URL and the user's aimlapi.com key (plus the
// attribution pair callOpenAI injects). Without this entry the BYOK
// "same as chat" branch in pickProvider() rejects an aimlapi.com chat
// snapshot and extraction silently falls back to unrelated ANTHROPIC/OPENAI
// credentials — the exact vendor surprise that branch exists to prevent.
// The default model matches FAST_MODEL_BY_PROTOCOL.aimlapi in the web app.
aimlapi: {
model: 'google/gemini-3.6-flash',
baseUrl: AIMLAPI_DEFAULT_BASE_URL,
},
};

// Some Settings -> Media providers credentials are usable for text
Expand Down Expand Up @@ -265,6 +280,13 @@ function envKeyFor(provider) {
|| ''
);
}
if (provider === 'aimlapi') {
return (
process.env.OD_AIMLAPI_API_KEY?.trim()
|| process.env.AIMLAPI_API_KEY?.trim()
|| ''
);
}
return '';
}

Expand Down Expand Up @@ -828,6 +850,10 @@ async function callOpenAI(provider, system, user) {
...(provider.kind === 'aihubmix' && AIHUBMIX_APP_CODE
? { 'APP-Code': AIHUBMIX_APP_CODE }
: {}),
// aimlapi.com routes through this same OpenAI-compatible path and
// expects the attribution pair on EVERY request it serves, the
// extractor's included — a missing pair serves fine but untagged.
...(provider.kind === 'aimlapi' ? aimlapiAttributionHeaders() : {}),
},
body: JSON.stringify({
model: provider.model,
Expand Down
8 changes: 4 additions & 4 deletions apps/daemon/src/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const protocol = body.protocol;
if (
typeof protocol !== 'string' ||
!['anthropic', 'openai', 'azure', 'google', 'ollama', 'senseaudio', 'aihubmix', 'bedrock'].includes(protocol)
!['aimlapi', 'anthropic', 'openai', 'azure', 'google', 'ollama', 'senseaudio', 'aihubmix', 'bedrock'].includes(protocol)
) {
return sendApiError(
res,
400,
'BAD_REQUEST',
'protocol must be one of anthropic|openai|azure|google|ollama|senseaudio|aihubmix|bedrock',
'protocol must be one of aimlapi|anthropic|openai|azure|google|ollama|senseaudio|aihubmix|bedrock',
);
}
// AIHubMix's catalogue (GET /api/v1/models?type=llm) is public, so its
Expand Down Expand Up @@ -286,13 +286,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const protocol = body.protocol;
if (
typeof protocol !== 'string' ||
!['anthropic', 'openai', 'azure', 'google', 'ollama', 'senseaudio', 'aihubmix', 'bedrock'].includes(protocol)
!['aimlapi', 'anthropic', 'openai', 'azure', 'google', 'ollama', 'senseaudio', 'aihubmix', 'bedrock'].includes(protocol)
) {
return sendApiError(
res,
400,
'BAD_REQUEST',
'protocol must be one of anthropic|openai|azure|google|ollama|senseaudio|aihubmix|bedrock',
'protocol must be one of aimlapi|anthropic|openai|azure|google|ollama|senseaudio|aihubmix|bedrock',
);
}
const apiKeyRequired = protocol !== 'bedrock';
Expand Down
8 changes: 8 additions & 0 deletions apps/daemon/src/routes/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,20 @@ function isMemoryType(value: unknown): value is MemoryType {
}

function isExtractionProvider(value: unknown): value is MemoryExtractionProvider {
// Must stay exhaustive over MemoryExtractionProvider: a protocol the type
// admits but this guard omits is rejected here as an 'invalid extraction
// provider', so the user cannot save an override for a vendor the extractor
// itself supports. `senseaudio`, `aihubmix` and `aimlapi` all reach the
// extractor through the OpenAI-compatible path in memory-llm.ts.
return (
value === 'anthropic'
|| value === 'openai'
|| value === 'azure'
|| value === 'google'
|| value === 'ollama'
|| value === 'senseaudio'
|| value === 'aihubmix'
|| value === 'aimlapi'
);
}

Expand Down
16 changes: 16 additions & 0 deletions apps/daemon/src/runtimes/byok-opencode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ByokChatProviderConfig } from '@open-design/contracts';
import { aimlapiAttributionHeaders } from '../integrations/aimlapi.js';

export const BYOK_OPENCODE_AGENT_ID = 'byok-opencode';
export const BYOK_OPENCODE_PROVIDER_ID = 'open-design-byok';
Expand All @@ -16,6 +17,7 @@ const DEFAULT_BASE_URL_BY_PROTOCOL: Record<ByokChatProviderConfig['protocol'], s
ollama: 'https://ollama.com',
senseaudio: 'https://api.senseaudio.cn',
aihubmix: 'https://aihubmix.com/v1',
aimlapi: 'https://api.aimlapi.com/v1',
};

type ProviderPackage =
Expand Down Expand Up @@ -237,6 +239,20 @@ function buildProviderEntry(
...apiKeyOption,
},
};
case 'aimlapi':
// This runtime — not the /api/proxy/* routes — is what serves real BYOK
// chat, so the aimlapi.com attribution pair has to ride here or it only
// ever covers Test connection and model discovery. The options object is
// handed to the provider factory, which forwards `headers` on every
// upstream request.
return {
npm: '@ai-sdk/openai-compatible',
options: {
baseURL: baseUrl,
...apiKeyOption,
headers: aimlapiAttributionHeaders(),
},
};
case 'senseaudio':
case 'aihubmix':
return {
Expand Down
Loading
Loading