From d994406d18801e047190c7951fe76c73409d3410 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Wed, 26 Aug 2026 15:45:30 +0500 Subject: [PATCH 01/10] feat(aimlapi): add aimlapi.com as a BYOK chat provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aimlapi.com is an OpenAI-wire-compatible aggregator: one key fronts ~900 models. Modelled on the AIHubMix integration, which is the closest existing case here — same wire shape, same "aggregator with a fixed attribution header" problem. Attribution follows AIHubMix's own reasoning verbatim: every outbound call point funnels through `aimlapiHeaders()` instead of hand-building `Authorization` inline, so "every aimlapi.com request carries our attribution" stays enforceable in one place rather than re-derived per call site. aimlapi.com wants X-AIMLAPI-Source + X-AIMLAPI-Partner-ID on everything it serves, and a missing header is silent — the request succeeds, it just serves untagged. The daemon gets a dedicated /api/proxy/aimlapi/stream rather than a hostname branch inside the generic OpenAI route. Two reasons: the pair then rides unconditionally instead of depending on a hostname match, and it keeps the picker tab -> daemon log line -> upstream call chain readable, which is the same argument the AIHubMix client documents. It also keeps the pair out of the browser bundle, where a user could strip or forge it. Chat only. Media generation (the image/video/speech tool loop AIHubMix carries) is deliberately out of scope for this change. Typecheck pending: the workspace install had not finished when this was committed. Co-Authored-By: Claude Opus 5 --- apps/daemon/src/connectionTest.ts | 27 ++- apps/daemon/src/integrations/aimlapi.ts | 83 +++++++++ apps/daemon/src/routes/chat.ts | 168 ++++++++++++++++++- apps/web/src/providers/aimlapi-compatible.ts | 36 ++++ apps/web/src/state/apiProtocols.ts | 1 + apps/web/src/types.ts | 1 + 6 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 apps/daemon/src/integrations/aimlapi.ts create mode 100644 apps/web/src/providers/aimlapi-compatible.ts diff --git a/apps/daemon/src/connectionTest.ts b/apps/daemon/src/connectionTest.ts index 47f1a6d6378..dcf0110aded 100644 --- a/apps/daemon/src/connectionTest.ts +++ b/apps/daemon/src/connectionTest.ts @@ -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'; @@ -945,7 +946,13 @@ function inspectProviderCompletion( const obj = data && typeof data === 'object' ? data as Record : 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): @@ -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 / diff --git a/apps/daemon/src/integrations/aimlapi.ts b/apps/daemon/src/integrations/aimlapi.ts new file mode 100644 index 00000000000..2a1cda2cbf2 --- /dev/null +++ b/apps/daemon/src/integrations/aimlapi.ts @@ -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'; + +/** + * `/` — 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 { + 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 { + 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; + } +} diff --git a/apps/daemon/src/routes/chat.ts b/apps/daemon/src/routes/chat.ts index d8be899b75b..3859c840347 100644 --- a/apps/daemon/src/routes/chat.ts +++ b/apps/daemon/src/routes/chat.ts @@ -31,6 +31,7 @@ import { aihubmixOriginFromBase, classifyAIHubMixModel, } from '../integrations/aihubmix.js'; +import { aimlapiHeaders, AIMLAPI_DEFAULT_BASE_URL } from '../integrations/aimlapi.js'; import { isSafeId as isSafeProjectId } from '../projects.js'; import { projectKindToTracking } from '@open-design/contracts/analytics'; import { proxyDispatcherRequestInit, validateUserProviderBaseUrl } from '../connectionTest.js'; @@ -208,13 +209,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 @@ -286,13 +287,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'; @@ -1179,6 +1180,165 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { } }); + // aimlapi.com: OpenAI-wire-compatible aggregator (~900 models behind one + // key). Wire-identical to the generic OpenAI route, so this is that route + // with one difference — every request carries the aimlapi.com attribution + // pair via aimlapiHeaders(). A dedicated route rather than a hostname branch + // in the OpenAI one keeps "picker tab -> daemon log line -> upstream call" + // readable end to end, the same reasoning the AIHubMix client documents. + app.post('/api/proxy/aimlapi/stream', async (req, res) => { + /** @type {Partial} */ + const proxyBody = req.body || {}; + if (rejectProxyPluginContext(proxyBody, res)) return; + const { baseUrl, apiKey, model, systemPrompt, messages, maxTokens } = + proxyBody; + if (!baseUrl || !apiKey || !model) { + return sendApiError( + res, + 400, + 'BAD_REQUEST', + 'baseUrl, apiKey, and model are required', + ); + } + + const validated = await validateExternalApiBaseUrl(baseUrl); + if (validated.error) { + return sendApiError( + res, + validated.forbidden ? 403 : 400, + validated.forbidden ? 'FORBIDDEN' : 'BAD_REQUEST', + validated.error, + ); + } + const reasoningDenial = authorizeReasoningEgress({ + policy: proxyBody.reasoningExecution, + routeKind: 'proxy', + provider: 'aimlapi', + resolvedBaseUrl: baseUrl, + model, + }); + if (reasoningDenial) return sendReasoningEgressDenial(res, reasoningDenial); + + const url = appendVersionedApiPath(baseUrl, '/chat/completions'); + console.log( + `[proxy:aimlapi] ${req.method} ${validated.parsed!.hostname} model=${model}`, + ); + + const payloadMessages = Array.isArray(messages) ? [...messages] : []; + if (typeof systemPrompt === 'string' && systemPrompt) { + payloadMessages.unshift({ role: 'system', content: systemPrompt }); + } + + const effectiveMaxTokens = + typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 8192; + const payload: any = { + model, + messages: payloadMessages, + ...buildOpenAIChatTokenParam(model, effectiveMaxTokens), + stream: true, + }; + const retryPayload = { + model, + messages: payloadMessages, + ...buildMaxCompletionTokensParam(effectiveMaxTokens), + stream: true, + }; + const canRetryUnsupportedMaxTokens = isAzureOpenAIHostname( + validated.parsed!.hostname, + ); + + const sse = createSseResponse(res); + let proxyDispatcher: ReturnType | null = null; + try { + proxyDispatcher = proxyDispatcherRequestInit(); + const signal = clientDisconnectSignal(res); + sse.send('start', { model }); + const requestInit = { + ...proxyDispatcher.requestInit, + signal, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // aimlapiHeaders carries Bearer auth AND the attribution pair + // aimlapi.com expects on every request. Building the header set in + // one helper is what keeps that true when this route changes. + ...aimlapiHeaders(apiKey), + 'HTTP-Referer': 'https://opendesign.dev', + 'X-Title': 'OpenDesign', + }, + redirect: 'error' as const, + }; + let response = await fetch(url, { + ...requestInit, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + let errorText = await response.text(); + if ( + canRetryUnsupportedMaxTokens && + response.status === 400 && + isUnsupportedMaxTokensError(errorText) + ) { + console.warn( + `[proxy:aimlapi] retrying Azure-hosted request with max_completion_tokens model=${model}`, + ); + response = await fetch(url, { + ...requestInit, + body: JSON.stringify(retryPayload), + }); + errorText = response.ok ? '' : await response.text(); + } + if (!response.ok) { + console.error( + `[proxy:aimlapi] upstream error: ${response.status} ${redactAuthTokens(errorText)}`, + ); + sendProxyError(sse, `Upstream error: ${response.status}`, { + code: proxyErrorCode(response.status), + details: errorText, + retryable: response.status === 429 || response.status >= 500, + }); + return sse.end(); + } + } + + let ended = false; + const guard = createDeltaGuard(sse); + await streamUpstreamSse(response, ({ payload, data }: any) => { + if (payload === '[DONE]') { + sse.send('end', {}); + ended = true; + return true; + } + if (!data) return false; + const streamError = extractStreamErrorMessage(data); + if (streamError) { + sendProxyError(sse, `Provider error: ${streamError}`, { details: data }); + ended = true; + return true; + } + const delta = extractOpenAIText(data); + if (delta) { + guard.sendDelta(delta); + if (guard.contaminated) { + sse.send('end', {}); + ended = true; + return true; + } + } + return false; + }); + if (!ended) sse.send('end', {}); + sse.end(); + } catch (err: any) { + console.error(`[proxy:aimlapi] internal error: ${err.message}`); + sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' }); + sse.end(); + } finally { + await proxyDispatcher?.close(); + } + }); + app.post('/api/proxy/azure/stream', async (req, res) => { /** @type {Partial} */ const proxyBody = req.body || {}; diff --git a/apps/web/src/providers/aimlapi-compatible.ts b/apps/web/src/providers/aimlapi-compatible.ts new file mode 100644 index 00000000000..9ca2417800e --- /dev/null +++ b/apps/web/src/providers/aimlapi-compatible.ts @@ -0,0 +1,36 @@ +/** + * aimlapi.com chat completions provider. aimlapi.com is an OpenAI-wire- + * compatible aggregator gateway (POST /v1/chat/completions, Bearer auth, SSE + * delta frames + [DONE]), so the only thing that differs from + * streamMessageOpenAI is the daemon proxy endpoint — keeping a dedicated client + * makes the picker tab -> daemon log line -> upstream call chain readable end + * to end and leaves room for aimlapi.com-specific divergence (the + * X-AIMLAPI-Source / X-AIMLAPI-Partner-ID attribution pair, injected + * daemon-side). + * + * Routes through the daemon proxy to avoid browser CORS issues and to keep the + * attribution pair out of the browser bundle, where a user could strip or + * forge it. BYOK — the key stays on the user's machine. + */ +import type { AppConfig, ChatMessage } from '../types'; +import type { StreamHandlers } from './anthropic'; +import { streamProxyEndpoint, type ProxyContext } from './api-proxy'; + +export async function streamMessageAimlapi( + cfg: AppConfig, + system: string, + history: ChatMessage[], + signal: AbortSignal, + handlers: StreamHandlers, + context?: ProxyContext, +): Promise { + return streamProxyEndpoint( + '/api/proxy/aimlapi/stream', + cfg, + system, + history, + signal, + handlers, + context, + ); +} diff --git a/apps/web/src/state/apiProtocols.ts b/apps/web/src/state/apiProtocols.ts index 6b7080b518b..0cac11fb99a 100644 --- a/apps/web/src/state/apiProtocols.ts +++ b/apps/web/src/state/apiProtocols.ts @@ -223,6 +223,7 @@ export const DEFAULT_BASE_URL_BY_PROTOCOL: Record = { ollama: 'https://ollama.com', senseaudio: 'https://api.senseaudio.cn', aihubmix: 'https://aihubmix.com/v1', + aimlapi: 'https://api.aimlapi.com/v1', bedrock: 'https://bedrock-runtime.us-east-1.amazonaws.com', }; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index aacebdca051..363fae40036 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -112,6 +112,7 @@ export type { export type ExecMode = 'daemon' | 'api'; export type ApiProtocol = + | 'aimlapi' | 'anthropic' | 'openai' | 'azure' From 8b4ca56d3ddf48b1310a4c170e64514747e2a8e5 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Wed, 26 Aug 2026 15:54:57 +0500 Subject: [PATCH 02/10] feat(aimlapi): surface aimlapi.com across the protocol maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the previous commit. `ApiProtocol` is exhaustively mapped in several `Record` tables, so adding the union member is only half the change — the compiler names every table that still has to answer for it, and each one is a real user-visible surface: - suggested models: the flagships aimlapi.com marks hottest, so the BYOK dropdown opens on something useful rather than empty - fast model: gemini-3.6-flash, for the memory extractor's auto pill - protocol tab, label, key placeholder, and the console link that sends users to https://aimlapi.com/app/keys - `ConnectionTestProtocol` and `MemoryExtractionProvider` in the contracts package, so the key smoke test and the memory extractor accept it Verified: typecheck clean in all three packages — daemon, web, contracts. Note for anyone repeating this: the daemon resolves `@open-design/contracts` through `dist`, so a contracts source edit does nothing until that package is rebuilt. The daemon's postinstall runs its own tsc, which is where the two contract mismatches surfaced. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/SettingsDialog.tsx | 4 ++++ apps/web/src/state/apiProtocols.ts | 15 +++++++++++++++ apps/web/src/utils/apiProtocol.ts | 2 ++ packages/contracts/src/api/connectionTest.ts | 1 + packages/contracts/src/api/memory.ts | 1 + 5 files changed, 23 insertions(+) diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 3806b165f70..2d7ffe4907f 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -816,6 +816,10 @@ function byokDraftBaseUrlHost(value: string): string | undefined { } const API_KEY_CONSOLE_LINKS: Record = { + aimlapi: { + host: 'aimlapi.com', + url: 'https://aimlapi.com/app/keys', + }, anthropic: { host: 'console.anthropic.com', url: 'https://console.anthropic.com/settings/keys', diff --git a/apps/web/src/state/apiProtocols.ts b/apps/web/src/state/apiProtocols.ts index 0cac11fb99a..3b735b12df8 100644 --- a/apps/web/src/state/apiProtocols.ts +++ b/apps/web/src/state/apiProtocols.ts @@ -20,6 +20,17 @@ import type { ApiProtocol } from '../types'; // completions endpoint speaks the same JSON shape; the deployment name // the user types in the model field is what's variable, not the API. export const SUGGESTED_MODELS_BY_PROTOCOL: Record = { + // aimlapi.com fronts ~900 models behind one key; these are the flagships the + // catalog marks hottest, which is what the dropdown should open on. + aimlapi: [ + 'openai/gpt-5.6-terra-pro', + 'anthropic/claude-sonnet-5', + 'anthropic/claude-opus-5', + 'google/gemini-3.6-flash', + 'deepseek/deepseek-v4-pro', + 'x-ai/grok-4-5', + 'moonshot/kimi-k3', + ], anthropic: [ 'claude-opus-4-5', 'claude-sonnet-4-5', @@ -162,6 +173,7 @@ export const SUGGESTED_MODELS_BY_PROTOCOL: Record = { + aimlapi: 'google/gemini-3.6-flash', anthropic: 'claude-haiku-4-5', openai: 'gpt-4o-mini', azure: 'gpt-4o-mini', @@ -188,9 +200,11 @@ export const API_PROTOCOL_TABS: ReadonlyArray<{ { id: 'ollama', title: 'Ollama Cloud' }, { id: 'senseaudio', title: 'SenseAudio' }, { id: 'aihubmix', title: 'AIHubMix' }, + { id: 'aimlapi', title: 'aimlapi.com' }, ]; export const API_PROTOCOL_LABELS: Record = { + aimlapi: 'aimlapi.com', anthropic: 'Anthropic API', openai: 'OpenAI API', azure: 'Azure OpenAI', @@ -202,6 +216,7 @@ export const API_PROTOCOL_LABELS: Record = { }; export const API_KEY_PLACEHOLDERS: Record = { + aimlapi: 'aimlapi.com API key', anthropic: 'sk-ant-...', openai: 'sk-...', azure: 'azure key', diff --git a/apps/web/src/utils/apiProtocol.ts b/apps/web/src/utils/apiProtocol.ts index 596e874cdcf..39371d23287 100644 --- a/apps/web/src/utils/apiProtocol.ts +++ b/apps/web/src/utils/apiProtocol.ts @@ -2,6 +2,7 @@ import { isOpenAICompatible } from '../providers/openai-compatible'; import type { ApiProtocol, AppConfig } from '../types'; const API_PROTOCOL_LABELS: Record = { + aimlapi: 'aimlapi.com', anthropic: 'Anthropic API', openai: 'OpenAI API', azure: 'Azure OpenAI', @@ -13,6 +14,7 @@ const API_PROTOCOL_LABELS: Record = { }; const API_PROTOCOL_AGENT_IDS: Record = { + aimlapi: 'aimlapi-api', anthropic: 'anthropic-api', openai: 'openai-api', azure: 'azure-openai-api', diff --git a/packages/contracts/src/api/connectionTest.ts b/packages/contracts/src/api/connectionTest.ts index 2cedf59695f..c5678bc91f0 100644 --- a/packages/contracts/src/api/connectionTest.ts +++ b/packages/contracts/src/api/connectionTest.ts @@ -249,6 +249,7 @@ export interface ConnectionTestDiagnostics { } export type ConnectionTestProtocol = + | 'aimlapi' | 'anthropic' | 'openai' | 'azure' diff --git a/packages/contracts/src/api/memory.ts b/packages/contracts/src/api/memory.ts index 20d61cc1192..105053db2a1 100644 --- a/packages/contracts/src/api/memory.ts +++ b/packages/contracts/src/api/memory.ts @@ -140,6 +140,7 @@ export interface MemoryListResponse { * ollama and senseaudio through the same callOpenAI path since the * wire protocol is identical. */ export type MemoryExtractionProvider = + | 'aimlapi' | 'anthropic' | 'openai' | 'azure' From e1e98589485b9b887965e76b7004b9c2a3b6e0f1 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Wed, 26 Aug 2026 16:17:54 +0500 Subject: [PATCH 03/10] fix(aimlapi): default to the gpt-5.6-terra alias, not the -pro variant Both ids resolve, but openai/gpt-5.6-terra is the intended alias for the suggested-model default; -pro is a separate, heavier SKU. Co-Authored-By: Claude Opus 5 --- apps/web/src/state/apiProtocols.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/state/apiProtocols.ts b/apps/web/src/state/apiProtocols.ts index 3b735b12df8..af2afd8cd6b 100644 --- a/apps/web/src/state/apiProtocols.ts +++ b/apps/web/src/state/apiProtocols.ts @@ -23,7 +23,7 @@ export const SUGGESTED_MODELS_BY_PROTOCOL: Record Date: Wed, 26 Aug 2026 16:21:34 +0500 Subject: [PATCH 04/10] fix(aimlapi): show the aimlapi.com tab first in the BYOK picker The protocol tab was appended, putting it last in the picker while every other table in the file already lists it first alphabetically. Moved to the front so the ordering is consistent with the labels, placeholders and console-link maps. Co-Authored-By: Claude Opus 5 --- apps/web/src/state/apiProtocols.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/state/apiProtocols.ts b/apps/web/src/state/apiProtocols.ts index af2afd8cd6b..f89025fe18a 100644 --- a/apps/web/src/state/apiProtocols.ts +++ b/apps/web/src/state/apiProtocols.ts @@ -193,6 +193,7 @@ export const API_PROTOCOL_TABS: ReadonlyArray<{ id: ApiProtocol; title: string; }> = [ + { id: 'aimlapi', title: 'aimlapi.com' }, { id: 'anthropic', title: 'Anthropic' }, { id: 'openai', title: 'OpenAI' }, { id: 'azure', title: 'Azure OpenAI' }, @@ -200,7 +201,6 @@ export const API_PROTOCOL_TABS: ReadonlyArray<{ { id: 'ollama', title: 'Ollama Cloud' }, { id: 'senseaudio', title: 'SenseAudio' }, { id: 'aihubmix', title: 'AIHubMix' }, - { id: 'aimlapi', title: 'aimlapi.com' }, ]; export const API_PROTOCOL_LABELS: Record = { From 59a19f352c621acab7e421924a1aae09f97c6753 Mon Sep 17 00:00:00 2001 From: Stan Date: Wed, 26 Aug 2026 17:34:06 +0500 Subject: [PATCH 05/10] fix: register aimlapi.com in KNOWN_PROVIDERS with the correct protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'aimlapi' protocol is already fully wired natively (state/apiProtocols.ts: its own tab, suggested models, default model, labels, and a dedicated providers/aimlapi-compatible.ts handler) — but KNOWN_PROVIDERS had no entry for it, so defaultApiProtocolConfig()/switchApiProtocolConfig() couldn't resolve a base URL when the aimlapi.com tab was selected in Settings: the Base URL field fell back to empty/placeholder instead of prefilling https://api.aimlapi.com/v1. Adds that entry with protocol: 'aimlapi' (matching the existing tab id) and a preferredModels list mirrored from SUGGESTED_MODELS_BY_PROTOCOL.aimlapi so the two stay in sync. --- apps/web/src/state/config.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/web/src/state/config.ts b/apps/web/src/state/config.ts index 4c69e901d0a..22b630684ab 100644 --- a/apps/web/src/state/config.ts +++ b/apps/web/src/state/config.ts @@ -140,6 +140,32 @@ export interface KnownProvider { // presets and are reconciled with the live account catalogue before automatic // selection. They are not a replacement for provider model discovery. export const KNOWN_PROVIDERS: KnownProvider[] = [ + { + label: 'aimlapi.com', + // Native protocol (see state/apiProtocols.ts), not a KNOWN_PROVIDERS-only + // OpenAI-compatible preset — the 'aimlapi' tab already has its own request + // handling in providers/aimlapi-compatible.ts. This entry only exists so + // defaultApiProtocolConfig()/switchApiProtocolConfig() (below) can resolve + // a baseUrl for that protocol; it must not be 'openai' or it collides with + // the plain OpenAI preset's default. + protocol: 'aimlapi', + baseUrl: 'https://api.aimlapi.com/v1', + // Mirrors SUGGESTED_MODELS_BY_PROTOCOL.aimlapi in state/apiProtocols.ts — + // keep the two in sync rather than curating a second list here. + preferredModels: [ + 'openai/gpt-5.6-terra', + 'anthropic/claude-sonnet-5', + 'anthropic/claude-opus-5', + 'google/gemini-3.6-flash', + 'deepseek/deepseek-v4-pro', + 'x-ai/grok-4-5', + 'moonshot/kimi-k3', + ], + apiKeyConsoleLink: { + host: 'aimlapi.com', + url: 'https://aimlapi.com/app/keys?utm_source=open_design&utm_medium=provider_preset&utm_campaign=aimlapi_byok', + }, + }, { label: 'Anthropic (Claude)', protocol: 'anthropic', From ebb78da15c342d2ef41bfeb312b2cdcbe05ec25c Mon Sep 17 00:00:00 2001 From: Stan Date: Wed, 26 Aug 2026 18:05:36 +0500 Subject: [PATCH 06/10] fix(aimlapi): recognize aimlapi.com as a supported BYOK chat protocol Registering aimlapi.com in Settings (KNOWN_PROVIDERS/BYOK_PROVIDER_PRESET_SPECS) and daemon model listing wasn't enough to actually start a chat: the run preflight in ProjectView.tsx and the daemon's OpenCode provider mapper both enumerate supported BYOK protocols explicitly, and 'aimlapi' was missing from both lists plus the shared ByokChatProtocol contract type. That made every run treated as an unconfigured provider and bounced back to Settings even with a valid, saved, tested API key. - packages/contracts: add 'aimlapi' to ByokChatProtocol - apps/daemon/runtimes/byok-opencode: map aimlapi to @ai-sdk/openai-compatible - apps/web/ProjectView: recognize aimlapi in the BYOK run-preflight gate - apps/daemon/provider-models: aimlapi model listing (Load models) support - apps/web/state/config: surface aimlapi.com first in the BYOK provider grid --- .../src/integrations/provider-models.ts | 13 ++++++++-- apps/daemon/src/runtimes/byok-opencode.ts | 2 ++ .../tests/runtimes/byok-opencode.test.ts | 25 +++++++++++++++++++ apps/web/src/components/ProjectView.tsx | 3 ++- apps/web/src/state/config.ts | 1 + packages/contracts/src/api/chat.ts | 3 ++- 6 files changed, 43 insertions(+), 4 deletions(-) diff --git a/apps/daemon/src/integrations/provider-models.ts b/apps/daemon/src/integrations/provider-models.ts index 53ffa2d70ea..a8314f0dda3 100644 --- a/apps/daemon/src/integrations/provider-models.ts +++ b/apps/daemon/src/integrations/provider-models.ts @@ -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; @@ -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') { @@ -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 @@ -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 []; diff --git a/apps/daemon/src/runtimes/byok-opencode.ts b/apps/daemon/src/runtimes/byok-opencode.ts index f49ffcdd2f5..b2990ff34dc 100644 --- a/apps/daemon/src/runtimes/byok-opencode.ts +++ b/apps/daemon/src/runtimes/byok-opencode.ts @@ -16,6 +16,7 @@ const DEFAULT_BASE_URL_BY_PROTOCOL: Record { }, ); + it('builds OpenAI-compatible provider config for aimlapi.com', () => { + const out = buildOpenCodeByokProviderConfig( + { + protocol: 'aimlapi', + apiKey: 'sk-aimlapi-secret', + baseUrl: 'https://api.aimlapi.com/v1', + }, + 'openai/gpt-5.6-terra', + ); + + expect(out?.modelId).toBe('open-design-byok/openai/gpt-5.6-terra'); + expect(out?.env).toEqual({ [BYOK_OPENCODE_API_KEY_ENV]: 'sk-aimlapi-secret' }); + expect(out?.config).toMatchObject({ + provider: { + [BYOK_OPENCODE_PROVIDER_ID]: { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: 'https://api.aimlapi.com/v1', + apiKey: `{env:${BYOK_OPENCODE_API_KEY_ENV}}`, + }, + }, + }, + }); + }); + it('maps other native BYOK protocols to provider packages', () => { expect(buildOpenCodeByokProviderConfig( { protocol: 'anthropic', apiKey: 'sk-ant', baseUrl: 'https://api.anthropic.com' }, diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 14c637454c9..845ecedbf4f 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -1705,7 +1705,8 @@ function isOpenCodeByokChatProtocol( protocol === 'google' || protocol === 'ollama' || protocol === 'senseaudio' || - protocol === 'aihubmix' + protocol === 'aihubmix' || + protocol === 'aimlapi' ); } diff --git a/apps/web/src/state/config.ts b/apps/web/src/state/config.ts index 22b630684ab..2db05338d21 100644 --- a/apps/web/src/state/config.ts +++ b/apps/web/src/state/config.ts @@ -529,6 +529,7 @@ export interface ByokProviderPresetConfig { } const BYOK_PROVIDER_PRESET_SPECS = [ + { id: 'aimlapi', title: 'aimlapi.com', providerLabel: 'aimlapi.com' }, { id: 'anthropic', title: 'Anthropic', providerLabel: 'Anthropic (Claude)' }, { id: 'openai', title: 'OpenAI', providerLabel: 'OpenAI' }, { id: 'atlascloud', title: 'Atlas Cloud', providerLabel: 'Atlas Cloud' }, diff --git a/packages/contracts/src/api/chat.ts b/packages/contracts/src/api/chat.ts index b809cfd7e65..3480ebc6c58 100644 --- a/packages/contracts/src/api/chat.ts +++ b/packages/contracts/src/api/chat.ts @@ -52,7 +52,8 @@ export type ByokChatProtocol = | 'google' | 'ollama' | 'senseaudio' - | 'aihubmix'; + | 'aihubmix' + | 'aimlapi'; export interface ByokChatProviderConfig { protocol: ByokChatProtocol; From 973e7a71d6517503b364d73b362a285640fa0383 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Wed, 26 Aug 2026 19:02:32 +0500 Subject: [PATCH 07/10] fix(aimlapi): attribute the runtime that actually serves chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real BYOK chat runs through the OpenCode runtime, not the /api/proxy/* routes: `streamMessage` — the web-provider dispatcher those routes exist for — is called nowhere outside `providers/`, and ProjectView dispatches `streamViaDaemon({ agentId: 'byok-opencode' })`. So the attribution pair added with the provider only ever covered Test connection and model discovery, while every actual inference request went out untagged. That is the failure mode worth naming: nothing breaks. The chat works, the connection test is green, and the traffic simply does not count. `buildProviderEntry` hands `options` to the provider factory, which forwards `headers` upstream, so the pair goes there. aimlapi.com now has its own case rather than sharing the senseaudio/aihubmix one, since it is the only one of the three that carries headers. Also adds aimlapi to `usesAnthropicProxy`'s allow-list. Without it the protocol falls through to the trailing baseUrl check and is treated as an Anthropic proxy, which sends image attachments down the Anthropic path for aimlapi.com users. Test asserts the pair on the generated runtime config; confirmed it fails when the headers are removed. Co-Authored-By: Claude Opus 5 --- apps/daemon/src/runtimes/byok-opencode.ts | 16 +++++++++++++- .../tests/runtimes/byok-opencode.test.ts | 21 +++++++++++++++++++ apps/web/src/utils/apiProtocol.ts | 1 + 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/daemon/src/runtimes/byok-opencode.ts b/apps/daemon/src/runtimes/byok-opencode.ts index b2990ff34dc..57b27ffb667 100644 --- a/apps/daemon/src/runtimes/byok-opencode.ts +++ b/apps/daemon/src/runtimes/byok-opencode.ts @@ -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'; @@ -238,9 +239,22 @@ 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': - case 'aimlapi': return { npm: '@ai-sdk/openai-compatible', options: { diff --git a/apps/daemon/tests/runtimes/byok-opencode.test.ts b/apps/daemon/tests/runtimes/byok-opencode.test.ts index 716113ba1e0..a2ddc3e2c5e 100644 --- a/apps/daemon/tests/runtimes/byok-opencode.test.ts +++ b/apps/daemon/tests/runtimes/byok-opencode.test.ts @@ -268,6 +268,27 @@ describe('byok-opencode runtime config', () => { }); }); + // This runtime serves real BYOK chat; the /api/proxy/* routes do not. If the + // pair is missing here it is missing from every actual inference request, + // while Test connection and model discovery still look correctly attributed + // — which is exactly the shape of failure that hides. + it('sends the aimlapi.com attribution pair on real chat traffic', () => { + const out = buildOpenCodeByokProviderConfig( + { + protocol: 'aimlapi', + apiKey: 'sk-aimlapi-secret', + baseUrl: 'https://api.aimlapi.com/v1', + }, + 'openai/gpt-5.6-terra', + ); + + const options = (out?.config as any).provider[BYOK_OPENCODE_PROVIDER_ID].options; + expect(options.headers['X-AIMLAPI-Source']).toBe('agent/open-design'); + expect(options.headers['X-AIMLAPI-Partner-ID']).toBe( + 'part_9TWZWFsyMyNrBDEENq5JaU0r', + ); + }); + it('maps other native BYOK protocols to provider packages', () => { expect(buildOpenCodeByokProviderConfig( { protocol: 'anthropic', apiKey: 'sk-ant', baseUrl: 'https://api.anthropic.com' }, diff --git a/apps/web/src/utils/apiProtocol.ts b/apps/web/src/utils/apiProtocol.ts index 39371d23287..3311826d47d 100644 --- a/apps/web/src/utils/apiProtocol.ts +++ b/apps/web/src/utils/apiProtocol.ts @@ -49,6 +49,7 @@ export function usesAnthropicProxy(cfg: AppConfig): boolean { cfg.apiProtocol === 'google' || cfg.apiProtocol === 'senseaudio' || cfg.apiProtocol === 'aihubmix' || + cfg.apiProtocol === 'aimlapi' || cfg.apiProtocol === 'bedrock' || cfg.apiProtocol === 'openai' ) { From 7a016064727a03bc0f95ed8cd07e00e5cc823450 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Wed, 26 Aug 2026 20:49:27 +0500 Subject: [PATCH 08/10] fix(aimlapi): wire the memory extractor and drop the duplicate chat route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two review findings on nexu-io/open-design#7461. Memory extraction never learned the protocol. ProjectView forwards byokChatProvider.provider = 'aimlapi' on every BYOK turn, but memory-llm.ts PROVIDER_DEFAULTS had no aimlapi entry, and pickProvider() only enters the "same as chat" branch when PROVIDER_DEFAULTS[provider] is truthy. An aimlapi.com chat therefore fell through to ANTHROPIC/ OPENAI credentials or skipped extraction outright — the "I'm chatting with X but memory used Y" surprise that snapshot path exists to prevent. In practice it skipped: the new spec, run against the old code, records zero outbound calls. Mirrors the AIHubMix wiring: a PROVIDER_DEFAULTS entry, an env-key lookup, and the attribution pair spread from callOpenAI so the extractor's own request is not silently untagged. isExtractionProvider() also rejected a saved { provider: 'aimlapi' } override as invalid. It had drifted from MemoryExtractionProvider, which already admits senseaudio and aihubmix as well; all three reach the extractor through the OpenAI-compatible path, so the guard is now exhaustive over the type. /api/proxy/aimlapi/stream was a 159-line near-copy of the OpenAI stream route, including an Azure max_completion_tokens retry that is dead on api.aimlapi.com. BYOK chat goes through byok-opencode, not /api/proxy/*, and its only caller — apps/web/src/providers/aimlapi-compatible.ts — was never imported. Both are removed rather than left to drift from the handler they were copied from; aimlapiHeaders() stays on the connection test, model listing and byok-opencode, which are the live call sites. --- apps/daemon/src/memory-llm.ts | 26 +++ apps/daemon/src/routes/chat.ts | 160 ------------------ apps/daemon/src/routes/memory.ts | 8 + apps/daemon/tests/memory-aimlapi-byok.test.ts | 122 +++++++++++++ apps/web/src/providers/aimlapi-compatible.ts | 36 ---- apps/web/src/state/config.ts | 5 +- 6 files changed, 159 insertions(+), 198 deletions(-) create mode 100644 apps/daemon/tests/memory-aimlapi-byok.test.ts delete mode 100644 apps/web/src/providers/aimlapi-compatible.ts diff --git a/apps/daemon/src/memory-llm.ts b/apps/daemon/src/memory-llm.ts index 0a22c23b735..290f88b8968 100644 --- a/apps/daemon/src/memory-llm.ts +++ b/apps/daemon/src/memory-llm.ts @@ -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'; @@ -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 @@ -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 ''; } @@ -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, diff --git a/apps/daemon/src/routes/chat.ts b/apps/daemon/src/routes/chat.ts index 3859c840347..ccc44909a80 100644 --- a/apps/daemon/src/routes/chat.ts +++ b/apps/daemon/src/routes/chat.ts @@ -31,7 +31,6 @@ import { aihubmixOriginFromBase, classifyAIHubMixModel, } from '../integrations/aihubmix.js'; -import { aimlapiHeaders, AIMLAPI_DEFAULT_BASE_URL } from '../integrations/aimlapi.js'; import { isSafeId as isSafeProjectId } from '../projects.js'; import { projectKindToTracking } from '@open-design/contracts/analytics'; import { proxyDispatcherRequestInit, validateUserProviderBaseUrl } from '../connectionTest.js'; @@ -1180,165 +1179,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { } }); - // aimlapi.com: OpenAI-wire-compatible aggregator (~900 models behind one - // key). Wire-identical to the generic OpenAI route, so this is that route - // with one difference — every request carries the aimlapi.com attribution - // pair via aimlapiHeaders(). A dedicated route rather than a hostname branch - // in the OpenAI one keeps "picker tab -> daemon log line -> upstream call" - // readable end to end, the same reasoning the AIHubMix client documents. - app.post('/api/proxy/aimlapi/stream', async (req, res) => { - /** @type {Partial} */ - const proxyBody = req.body || {}; - if (rejectProxyPluginContext(proxyBody, res)) return; - const { baseUrl, apiKey, model, systemPrompt, messages, maxTokens } = - proxyBody; - if (!baseUrl || !apiKey || !model) { - return sendApiError( - res, - 400, - 'BAD_REQUEST', - 'baseUrl, apiKey, and model are required', - ); - } - - const validated = await validateExternalApiBaseUrl(baseUrl); - if (validated.error) { - return sendApiError( - res, - validated.forbidden ? 403 : 400, - validated.forbidden ? 'FORBIDDEN' : 'BAD_REQUEST', - validated.error, - ); - } - const reasoningDenial = authorizeReasoningEgress({ - policy: proxyBody.reasoningExecution, - routeKind: 'proxy', - provider: 'aimlapi', - resolvedBaseUrl: baseUrl, - model, - }); - if (reasoningDenial) return sendReasoningEgressDenial(res, reasoningDenial); - - const url = appendVersionedApiPath(baseUrl, '/chat/completions'); - console.log( - `[proxy:aimlapi] ${req.method} ${validated.parsed!.hostname} model=${model}`, - ); - - const payloadMessages = Array.isArray(messages) ? [...messages] : []; - if (typeof systemPrompt === 'string' && systemPrompt) { - payloadMessages.unshift({ role: 'system', content: systemPrompt }); - } - - const effectiveMaxTokens = - typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 8192; - const payload: any = { - model, - messages: payloadMessages, - ...buildOpenAIChatTokenParam(model, effectiveMaxTokens), - stream: true, - }; - const retryPayload = { - model, - messages: payloadMessages, - ...buildMaxCompletionTokensParam(effectiveMaxTokens), - stream: true, - }; - const canRetryUnsupportedMaxTokens = isAzureOpenAIHostname( - validated.parsed!.hostname, - ); - - const sse = createSseResponse(res); - let proxyDispatcher: ReturnType | null = null; - try { - proxyDispatcher = proxyDispatcherRequestInit(); - const signal = clientDisconnectSignal(res); - sse.send('start', { model }); - const requestInit = { - ...proxyDispatcher.requestInit, - signal, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - // aimlapiHeaders carries Bearer auth AND the attribution pair - // aimlapi.com expects on every request. Building the header set in - // one helper is what keeps that true when this route changes. - ...aimlapiHeaders(apiKey), - 'HTTP-Referer': 'https://opendesign.dev', - 'X-Title': 'OpenDesign', - }, - redirect: 'error' as const, - }; - let response = await fetch(url, { - ...requestInit, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - let errorText = await response.text(); - if ( - canRetryUnsupportedMaxTokens && - response.status === 400 && - isUnsupportedMaxTokensError(errorText) - ) { - console.warn( - `[proxy:aimlapi] retrying Azure-hosted request with max_completion_tokens model=${model}`, - ); - response = await fetch(url, { - ...requestInit, - body: JSON.stringify(retryPayload), - }); - errorText = response.ok ? '' : await response.text(); - } - if (!response.ok) { - console.error( - `[proxy:aimlapi] upstream error: ${response.status} ${redactAuthTokens(errorText)}`, - ); - sendProxyError(sse, `Upstream error: ${response.status}`, { - code: proxyErrorCode(response.status), - details: errorText, - retryable: response.status === 429 || response.status >= 500, - }); - return sse.end(); - } - } - - let ended = false; - const guard = createDeltaGuard(sse); - await streamUpstreamSse(response, ({ payload, data }: any) => { - if (payload === '[DONE]') { - sse.send('end', {}); - ended = true; - return true; - } - if (!data) return false; - const streamError = extractStreamErrorMessage(data); - if (streamError) { - sendProxyError(sse, `Provider error: ${streamError}`, { details: data }); - ended = true; - return true; - } - const delta = extractOpenAIText(data); - if (delta) { - guard.sendDelta(delta); - if (guard.contaminated) { - sse.send('end', {}); - ended = true; - return true; - } - } - return false; - }); - if (!ended) sse.send('end', {}); - sse.end(); - } catch (err: any) { - console.error(`[proxy:aimlapi] internal error: ${err.message}`); - sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' }); - sse.end(); - } finally { - await proxyDispatcher?.close(); - } - }); - app.post('/api/proxy/azure/stream', async (req, res) => { /** @type {Partial} */ const proxyBody = req.body || {}; diff --git a/apps/daemon/src/routes/memory.ts b/apps/daemon/src/routes/memory.ts index 454d716ede1..2887cac74d7 100644 --- a/apps/daemon/src/routes/memory.ts +++ b/apps/daemon/src/routes/memory.ts @@ -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' ); } diff --git a/apps/daemon/tests/memory-aimlapi-byok.test.ts b/apps/daemon/tests/memory-aimlapi-byok.test.ts new file mode 100644 index 00000000000..82066c99d7d --- /dev/null +++ b/apps/daemon/tests/memory-aimlapi-byok.test.ts @@ -0,0 +1,122 @@ +import { promises as fsp } from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { extractWithLLM } from '../src/memory-llm.js'; +import { memoryDir, writeMemoryConfig } from '../src/memory.js'; +import { __resetExtractionsForTests } from '../src/memory-extractions.js'; +import { + AIMLAPI_PARTNER_ID, + AIMLAPI_SOURCE, +} from '../src/integrations/aimlapi.js'; + +const dataDir = path.join(process.env.OD_DATA_DIR as string, 'memory-aimlapi-byok-test'); +const originalFetch = globalThis.fetch; + +beforeEach(async () => { + await fsp.rm(memoryDir(dataDir), { recursive: true, force: true }); + __resetExtractionsForTests(); + // Chat auto-extraction defaults OFF product-wide; this spec covers the + // provider auto-pick inside the extractor, so opt in explicitly. + await writeMemoryConfig(dataDir, { chatExtractionEnabled: true }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +type Captured = { + url: string; + headers: Record; + body: string; +}; + +/** Capture the single outbound extractor call instead of performing it. */ +function captureOneCall(): { calls: Captured[] } { + const calls: Captured[] = []; + globalThis.fetch = async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + const headers: Record = {}; + // Header names are case-insensitive on the wire; normalise so assertions + // do not depend on the casing the caller happened to use. + new Headers(init?.headers ?? {}).forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + calls.push({ url, headers, body: String(init?.body ?? '') }); + return new Response(JSON.stringify({ choices: [{ message: { content: '{"entries":[]}' } }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + return { calls }; +} + +describe('memory-llm aimlapi.com BYOK snapshot', () => { + // ProjectView forwards byokChatProvider.provider = 'aimlapi' on every BYOK + // turn. pickProvider() only enters the "same as chat" branch when + // PROVIDER_DEFAULTS[provider] exists, so without an aimlapi entry the + // extractor silently falls back to ANTHROPIC/OPENAI credentials — the + // "I'm chatting with X but memory used Y" surprise this path prevents. + it('extracts against aimlapi.com rather than falling back to another vendor', async () => { + const { calls } = captureOneCall(); + + await extractWithLLM( + dataDir, + { userMessage: 'I prefer dark mode.', assistantMessage: 'Noted.' }, + { + projectRoot: null, + chatAgentId: null, + chatProvider: { + provider: 'aimlapi', + apiKey: 'test-aimlapi-key', + baseUrl: '', + apiVersion: '', + model: '', + }, + }, + ); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://api.aimlapi.com/v1/chat/completions'); + // An empty `model` must resolve to the aimlapi.com fast default, not to + // another provider's (gpt-4o-mini would mean we fell through to OpenAI). + expect(JSON.parse(calls[0].body).model).toBe('google/gemini-3.6-flash'); + expect(calls[0].headers.authorization).toBe('Bearer test-aimlapi-key'); + }); + + // aimlapi.com expects the attribution pair on EVERY request it serves, the + // memory extractor's included. A missing pair serves fine but is silently + // untagged, so only an assertion on the outgoing headers catches it. + it('carries the attribution pair on the extractor call', async () => { + const { calls } = captureOneCall(); + + await extractWithLLM( + dataDir, + { userMessage: 'I prefer dark mode.', assistantMessage: 'Noted.' }, + { + projectRoot: null, + chatAgentId: null, + chatProvider: { + provider: 'aimlapi', + apiKey: 'test-aimlapi-key', + baseUrl: 'https://api.aimlapi.com/v1', + apiVersion: '', + model: 'openai/gpt-5.6-terra', + }, + }, + ); + + expect(calls).toHaveLength(1); + expect(calls[0].headers['x-aimlapi-source']).toBe(AIMLAPI_SOURCE); + expect(calls[0].headers['x-aimlapi-partner-id']).toBe(AIMLAPI_PARTNER_ID); + // An explicit model must win over the default. + expect(JSON.parse(calls[0].body).model).toBe('openai/gpt-5.6-terra'); + }); +}); diff --git a/apps/web/src/providers/aimlapi-compatible.ts b/apps/web/src/providers/aimlapi-compatible.ts deleted file mode 100644 index 9ca2417800e..00000000000 --- a/apps/web/src/providers/aimlapi-compatible.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * aimlapi.com chat completions provider. aimlapi.com is an OpenAI-wire- - * compatible aggregator gateway (POST /v1/chat/completions, Bearer auth, SSE - * delta frames + [DONE]), so the only thing that differs from - * streamMessageOpenAI is the daemon proxy endpoint — keeping a dedicated client - * makes the picker tab -> daemon log line -> upstream call chain readable end - * to end and leaves room for aimlapi.com-specific divergence (the - * X-AIMLAPI-Source / X-AIMLAPI-Partner-ID attribution pair, injected - * daemon-side). - * - * Routes through the daemon proxy to avoid browser CORS issues and to keep the - * attribution pair out of the browser bundle, where a user could strip or - * forge it. BYOK — the key stays on the user's machine. - */ -import type { AppConfig, ChatMessage } from '../types'; -import type { StreamHandlers } from './anthropic'; -import { streamProxyEndpoint, type ProxyContext } from './api-proxy'; - -export async function streamMessageAimlapi( - cfg: AppConfig, - system: string, - history: ChatMessage[], - signal: AbortSignal, - handlers: StreamHandlers, - context?: ProxyContext, -): Promise { - return streamProxyEndpoint( - '/api/proxy/aimlapi/stream', - cfg, - system, - history, - signal, - handlers, - context, - ); -} diff --git a/apps/web/src/state/config.ts b/apps/web/src/state/config.ts index 2db05338d21..df9c38b537e 100644 --- a/apps/web/src/state/config.ts +++ b/apps/web/src/state/config.ts @@ -143,8 +143,9 @@ export const KNOWN_PROVIDERS: KnownProvider[] = [ { label: 'aimlapi.com', // Native protocol (see state/apiProtocols.ts), not a KNOWN_PROVIDERS-only - // OpenAI-compatible preset — the 'aimlapi' tab already has its own request - // handling in providers/aimlapi-compatible.ts. This entry only exists so + // OpenAI-compatible preset. BYOK chat is served by the OpenCode runtime + // (apps/daemon/src/runtimes/byok-opencode.ts), which is where the + // attribution pair is attached; this entry only exists so // defaultApiProtocolConfig()/switchApiProtocolConfig() (below) can resolve // a baseUrl for that protocol; it must not be 'openai' or it collides with // the plain OpenAI preset's default. From bf5bcd2b1361ffeb054f7cda8e025889c93efb5d Mon Sep 17 00:00:00 2001 From: aimlapi Date: Wed, 26 Aug 2026 21:02:32 +0500 Subject: [PATCH 09/10] refactor(aimlapi): list the provider after the first-party ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the picker-order review finding on nexu-io/open-design#7461. The entries were prepended, which put a third-party aggregator ahead of Anthropic and OpenAI in Settings -> BYOK, the onboarding picker and the protocol tabs. Record insertion order is not a display contract, but API_PROTOCOL_TABS, KNOWN_PROVIDERS and BYOK_PROVIDER_PRESET_SPECS are — they are a product default for every new BYOK setup, not an alphabetical coincidence. All three now append the entry last, where AIHubMix already sits among the native tabs. The Record maps listed aimlapi first only because it was prepended to the ApiProtocol union; the union member and the map keys move with it so the whole set reads as appended rather than mixed. ConnectionTestProtocol and MemoryExtractionProvider get the same treatment (ByokChatProtocol was already appended). Pure reordering: no key, value or behaviour changes, and DEFAULT_CONFIG .apiProtocol stays 'anthropic', so no install changes vendor. --- apps/web/src/components/SettingsDialog.tsx | 8 +-- apps/web/src/state/apiProtocols.ts | 30 +++++------ apps/web/src/state/config.ts | 56 ++++++++++---------- apps/web/src/types.ts | 2 +- apps/web/src/utils/apiProtocol.ts | 4 +- packages/contracts/src/api/connectionTest.ts | 2 +- packages/contracts/src/api/memory.ts | 4 +- 7 files changed, 53 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 2d7ffe4907f..19c73077ba9 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -816,10 +816,6 @@ function byokDraftBaseUrlHost(value: string): string | undefined { } const API_KEY_CONSOLE_LINKS: Record = { - aimlapi: { - host: 'aimlapi.com', - url: 'https://aimlapi.com/app/keys', - }, anthropic: { host: 'console.anthropic.com', url: 'https://console.anthropic.com/settings/keys', @@ -848,6 +844,10 @@ const API_KEY_CONSOLE_LINKS: Record host: 'aihubmix.com', url: 'https://aihubmix.com/?aff=JA1e', }, + aimlapi: { + host: 'aimlapi.com', + url: 'https://aimlapi.com/app/keys', + }, bedrock: { host: 'aws.amazon.com', url: 'https://aws.amazon.com/bedrock/', diff --git a/apps/web/src/state/apiProtocols.ts b/apps/web/src/state/apiProtocols.ts index f89025fe18a..6dc1f3d4d75 100644 --- a/apps/web/src/state/apiProtocols.ts +++ b/apps/web/src/state/apiProtocols.ts @@ -20,17 +20,6 @@ import type { ApiProtocol } from '../types'; // completions endpoint speaks the same JSON shape; the deployment name // the user types in the model field is what's variable, not the API. export const SUGGESTED_MODELS_BY_PROTOCOL: Record = { - // aimlapi.com fronts ~900 models behind one key; these are the flagships the - // catalog marks hottest, which is what the dropdown should open on. - aimlapi: [ - 'openai/gpt-5.6-terra', - 'anthropic/claude-sonnet-5', - 'anthropic/claude-opus-5', - 'google/gemini-3.6-flash', - 'deepseek/deepseek-v4-pro', - 'x-ai/grok-4-5', - 'moonshot/kimi-k3', - ], anthropic: [ 'claude-opus-4-5', 'claude-sonnet-4-5', @@ -113,6 +102,17 @@ export const SUGGESTED_MODELS_BY_PROTOCOL: Record = { - aimlapi: 'google/gemini-3.6-flash', anthropic: 'claude-haiku-4-5', openai: 'gpt-4o-mini', azure: 'gpt-4o-mini', @@ -186,6 +185,7 @@ export const FAST_MODEL_BY_PROTOCOL: Record = { ollama: 'gemma3:4b', senseaudio: 'senseaudio-s2-flash', aihubmix: 'gpt-4o-mini', + aimlapi: 'google/gemini-3.6-flash', bedrock: 'amazon.nova-lite-v1:0', }; @@ -193,7 +193,6 @@ export const API_PROTOCOL_TABS: ReadonlyArray<{ id: ApiProtocol; title: string; }> = [ - { id: 'aimlapi', title: 'aimlapi.com' }, { id: 'anthropic', title: 'Anthropic' }, { id: 'openai', title: 'OpenAI' }, { id: 'azure', title: 'Azure OpenAI' }, @@ -201,10 +200,10 @@ export const API_PROTOCOL_TABS: ReadonlyArray<{ { id: 'ollama', title: 'Ollama Cloud' }, { id: 'senseaudio', title: 'SenseAudio' }, { id: 'aihubmix', title: 'AIHubMix' }, + { id: 'aimlapi', title: 'aimlapi.com' }, ]; export const API_PROTOCOL_LABELS: Record = { - aimlapi: 'aimlapi.com', anthropic: 'Anthropic API', openai: 'OpenAI API', azure: 'Azure OpenAI', @@ -212,11 +211,11 @@ export const API_PROTOCOL_LABELS: Record = { ollama: 'Ollama Cloud API', senseaudio: 'SenseAudio API', aihubmix: 'AIHubMix API', + aimlapi: 'aimlapi.com', bedrock: 'AWS Bedrock', }; export const API_KEY_PLACEHOLDERS: Record = { - aimlapi: 'aimlapi.com API key', anthropic: 'sk-ant-...', openai: 'sk-...', azure: 'azure key', @@ -224,6 +223,7 @@ export const API_KEY_PLACEHOLDERS: Record = { ollama: 'Ollama API key', senseaudio: 'SenseAudio API key', aihubmix: 'sk-...', + aimlapi: 'aimlapi.com API key', bedrock: 'AWS credentials', }; diff --git a/apps/web/src/state/config.ts b/apps/web/src/state/config.ts index df9c38b537e..912121ceafe 100644 --- a/apps/web/src/state/config.ts +++ b/apps/web/src/state/config.ts @@ -140,33 +140,6 @@ export interface KnownProvider { // presets and are reconciled with the live account catalogue before automatic // selection. They are not a replacement for provider model discovery. export const KNOWN_PROVIDERS: KnownProvider[] = [ - { - label: 'aimlapi.com', - // Native protocol (see state/apiProtocols.ts), not a KNOWN_PROVIDERS-only - // OpenAI-compatible preset. BYOK chat is served by the OpenCode runtime - // (apps/daemon/src/runtimes/byok-opencode.ts), which is where the - // attribution pair is attached; this entry only exists so - // defaultApiProtocolConfig()/switchApiProtocolConfig() (below) can resolve - // a baseUrl for that protocol; it must not be 'openai' or it collides with - // the plain OpenAI preset's default. - protocol: 'aimlapi', - baseUrl: 'https://api.aimlapi.com/v1', - // Mirrors SUGGESTED_MODELS_BY_PROTOCOL.aimlapi in state/apiProtocols.ts — - // keep the two in sync rather than curating a second list here. - preferredModels: [ - 'openai/gpt-5.6-terra', - 'anthropic/claude-sonnet-5', - 'anthropic/claude-opus-5', - 'google/gemini-3.6-flash', - 'deepseek/deepseek-v4-pro', - 'x-ai/grok-4-5', - 'moonshot/kimi-k3', - ], - apiKeyConsoleLink: { - host: 'aimlapi.com', - url: 'https://aimlapi.com/app/keys?utm_source=open_design&utm_medium=provider_preset&utm_campaign=aimlapi_byok', - }, - }, { label: 'Anthropic (Claude)', protocol: 'anthropic', @@ -513,6 +486,33 @@ export const KNOWN_PROVIDERS: KnownProvider[] = [ 'deepseek-reasoner', ], }, + { + label: 'aimlapi.com', + // Native protocol (see state/apiProtocols.ts), not a KNOWN_PROVIDERS-only + // OpenAI-compatible preset. BYOK chat is served by the OpenCode runtime + // (apps/daemon/src/runtimes/byok-opencode.ts), which is where the + // attribution pair is attached; this entry only exists so + // defaultApiProtocolConfig()/switchApiProtocolConfig() (below) can resolve + // a baseUrl for that protocol; it must not be 'openai' or it collides with + // the plain OpenAI preset's default. + protocol: 'aimlapi', + baseUrl: 'https://api.aimlapi.com/v1', + // Mirrors SUGGESTED_MODELS_BY_PROTOCOL.aimlapi in state/apiProtocols.ts — + // keep the two in sync rather than curating a second list here. + preferredModels: [ + 'openai/gpt-5.6-terra', + 'anthropic/claude-sonnet-5', + 'anthropic/claude-opus-5', + 'google/gemini-3.6-flash', + 'deepseek/deepseek-v4-pro', + 'x-ai/grok-4-5', + 'moonshot/kimi-k3', + ], + apiKeyConsoleLink: { + host: 'aimlapi.com', + url: 'https://aimlapi.com/app/keys?utm_source=open_design&utm_medium=provider_preset&utm_campaign=aimlapi_byok', + }, + }, ]; export function defaultKnownProviderModel( @@ -530,7 +530,6 @@ export interface ByokProviderPresetConfig { } const BYOK_PROVIDER_PRESET_SPECS = [ - { id: 'aimlapi', title: 'aimlapi.com', providerLabel: 'aimlapi.com' }, { id: 'anthropic', title: 'Anthropic', providerLabel: 'Anthropic (Claude)' }, { id: 'openai', title: 'OpenAI', providerLabel: 'OpenAI' }, { id: 'atlascloud', title: 'Atlas Cloud', providerLabel: 'Atlas Cloud' }, @@ -560,6 +559,7 @@ const BYOK_PROVIDER_PRESET_SPECS = [ { id: 'minimax', title: 'MiniMax', providerLabel: 'MiniMax — Anthropic (CN)' }, { id: 'moonshot', title: 'Moonshot', providerLabel: 'Moonshot' }, { id: 'zhipu', title: 'Zhipu AI', providerLabel: 'Zhipu' }, + { id: 'aimlapi', title: 'aimlapi.com', providerLabel: 'aimlapi.com' }, ] as const; export const BYOK_PROVIDER_PRESETS: ReadonlyArray = diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 363fae40036..21be5d90624 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -112,7 +112,6 @@ export type { export type ExecMode = 'daemon' | 'api'; export type ApiProtocol = - | 'aimlapi' | 'anthropic' | 'openai' | 'azure' @@ -120,6 +119,7 @@ export type ApiProtocol = | 'ollama' | 'senseaudio' | 'aihubmix' + | 'aimlapi' | 'bedrock'; export type LiveArtifactTabId = `live:${string}`; diff --git a/apps/web/src/utils/apiProtocol.ts b/apps/web/src/utils/apiProtocol.ts index 3311826d47d..d2192a07ba4 100644 --- a/apps/web/src/utils/apiProtocol.ts +++ b/apps/web/src/utils/apiProtocol.ts @@ -2,7 +2,6 @@ import { isOpenAICompatible } from '../providers/openai-compatible'; import type { ApiProtocol, AppConfig } from '../types'; const API_PROTOCOL_LABELS: Record = { - aimlapi: 'aimlapi.com', anthropic: 'Anthropic API', openai: 'OpenAI API', azure: 'Azure OpenAI', @@ -10,11 +9,11 @@ const API_PROTOCOL_LABELS: Record = { ollama: 'Ollama Cloud API', senseaudio: 'SenseAudio API', aihubmix: 'AIHubMix API', + aimlapi: 'aimlapi.com', bedrock: 'AWS Bedrock', }; const API_PROTOCOL_AGENT_IDS: Record = { - aimlapi: 'aimlapi-api', anthropic: 'anthropic-api', openai: 'openai-api', azure: 'azure-openai-api', @@ -22,6 +21,7 @@ const API_PROTOCOL_AGENT_IDS: Record = { ollama: 'ollama-cloud-api', senseaudio: 'senseaudio-api', aihubmix: 'aihubmix-api', + aimlapi: 'aimlapi-api', bedrock: 'bedrock-api', }; diff --git a/packages/contracts/src/api/connectionTest.ts b/packages/contracts/src/api/connectionTest.ts index c5678bc91f0..03cc163a4d1 100644 --- a/packages/contracts/src/api/connectionTest.ts +++ b/packages/contracts/src/api/connectionTest.ts @@ -249,7 +249,6 @@ export interface ConnectionTestDiagnostics { } export type ConnectionTestProtocol = - | 'aimlapi' | 'anthropic' | 'openai' | 'azure' @@ -257,6 +256,7 @@ export type ConnectionTestProtocol = | 'ollama' | 'senseaudio' | 'aihubmix' + | 'aimlapi' | 'bedrock'; export interface ProviderTestRequest extends ReasoningExecutionRequestFields { diff --git a/packages/contracts/src/api/memory.ts b/packages/contracts/src/api/memory.ts index 105053db2a1..1c48ed3625d 100644 --- a/packages/contracts/src/api/memory.ts +++ b/packages/contracts/src/api/memory.ts @@ -140,14 +140,14 @@ export interface MemoryListResponse { * ollama and senseaudio through the same callOpenAI path since the * wire protocol is identical. */ export type MemoryExtractionProvider = - | 'aimlapi' | 'anthropic' | 'openai' | 'azure' | 'google' | 'ollama' | 'senseaudio' - | 'aihubmix'; + | 'aihubmix' + | 'aimlapi'; /** Masked version of MemoryExtractionConfig returned by GET endpoints — * the api key field is replaced with a 4-char tail so the settings UI From a90feecb63c04dcf73230756d571e8920ad57ea2 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Wed, 26 Aug 2026 21:05:04 +0500 Subject: [PATCH 10/10] fix(aimlapi): keep chat history across turns in BYOK OpenCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the transcript-family review finding on nexu-io/open-design#7461. ProjectView stamps each API-mode assistant turn with apiProtocolAgentId(config.apiProtocol) — 'aimlapi-api' for this tab — and then sends the run through streamViaDaemon({ agentId: 'byok-opencode' }). scopeHistoryToAgent walks back from the latest user turn and cuts the history at the first assistant turn whose agentId is not in the target's family; isSameTranscriptAgentFamily resolves that through the closed API_MODE_AGENT_IDS set, which 'aimlapi-api' had never joined. AIHubMix hit the same seam and is already a member. The effect is primary-path breakage for the feature this PR adds: from turn 2 on, an aimlapi.com BYOK chat ships only the latest user message and the model loses the conversation it just had. The new spec pins it — against the unfixed code the transcript collapses to exactly '## user\nmake the second step clearer'. Also labels 'aimlapi-api' in AGENT_LABELS so the chat header shows the provider instead of falling back to the raw agent id. --- apps/web/src/providers/daemon.ts | 1 + apps/web/src/utils/agentLabels.ts | 1 + apps/web/tests/providers/sse.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/apps/web/src/providers/daemon.ts b/apps/web/src/providers/daemon.ts index 580836b644a..bf82b06ab7c 100644 --- a/apps/web/src/providers/daemon.ts +++ b/apps/web/src/providers/daemon.ts @@ -80,6 +80,7 @@ const API_MODE_AGENT_IDS = new Set([ 'ollama-cloud-api', 'senseaudio-api', 'aihubmix-api', + 'aimlapi-api', 'bedrock-api', ]); diff --git a/apps/web/src/utils/agentLabels.ts b/apps/web/src/utils/agentLabels.ts index eba8d938e3b..8b170554717 100644 --- a/apps/web/src/utils/agentLabels.ts +++ b/apps/web/src/utils/agentLabels.ts @@ -23,6 +23,7 @@ const AGENT_LABELS: Record = { 'ollama-cloud-api': 'Ollama Cloud API via OpenCode', 'senseaudio-api': 'SenseAudio API via OpenCode', 'aihubmix-api': 'AIHubMix API via OpenCode', + 'aimlapi-api': 'aimlapi.com API via OpenCode', 'bedrock-api': 'AWS Bedrock via OpenCode', }; diff --git a/apps/web/tests/providers/sse.test.ts b/apps/web/tests/providers/sse.test.ts index e3e17210b96..ce2b1b1a723 100644 --- a/apps/web/tests/providers/sse.test.ts +++ b/apps/web/tests/providers/sse.test.ts @@ -536,6 +536,31 @@ describe('streamViaDaemon', () => { expect(transcript).toContain('make the second step clearer'); }); + // ProjectView stamps API-mode assistant turns with apiProtocolAgentId(protocol) + // and then routes the run through the BYOK OpenCode agent. An id missing from + // API_MODE_AGENT_IDS reads as a foreign agent, so scopeHistoryToAgent drops + // everything before it and turn 2 ships only the latest user message — the + // model silently loses the conversation it just had. + it('keeps aimlapi.com API-mode assistant context when routing through BYOK OpenCode', () => { + const transcript = buildDaemonTranscript( + [ + { id: '1', role: 'user', content: 'draft the registration flow' }, + { + id: '2', + role: 'assistant', + content: 'aimlapi response with design decisions', + agentId: 'aimlapi-api', + }, + { id: '3', role: 'user', content: 'make the second step clearer' }, + ], + 'byok-opencode', + ); + + expect(transcript).toContain('draft the registration flow'); + expect(transcript).toContain('aimlapi response with design decisions'); + expect(transcript).toContain('make the second step clearer'); + }); + it('extracts only the latest user prompt for telemetry', () => { expect( latestUserPromptFromHistory([