Skip to content

Write an AI provider

An AI provider in arka-deck consists of:

  • A manifest (static metadata: label, models, auth type)
  • An instance (user configuration: API key, model, baseUrl, default) persisted under <arkaHome>/providers/providers.db
  • A ProviderTester (connection check: quick check or live probe)
  • A ChatRuntime (agentic chat stream)
  • Optionally an LlmInvoker for 1-shot workers

API keys are encrypted AES-256-GCM on the server side, never exposed to the UI or logged.


addons/<provider-name>/
manifest.json ← kind: provider, status: draft
src/
index.ts ← exports tester + runtime
tester.ts ← ProviderTester impl
tester.test.ts
runtime.ts ← ChatRuntime impl
specs/SPEC-../
plans/PLAN-../

// addons/<name>/src/tester.ts
import type { ProviderTester, ProviderTesterInput, TestResult } from '../../../core/ports/outbound/provider-tester.js';
export class MyProviderTester implements ProviderTester {
async test(input: ProviderTesterInput): Promise<TestResult> {
if (input.kind === 'check') return this.runCheck(input);
return this.runLive(input);
}
private async runCheck(input: ProviderTesterInput): Promise<TestResult> {
if (!input.apiKey) return { ok: false, error: 'API key required' };
return { ok: true };
}
private async runLive(input: ProviderTesterInput): Promise<TestResult> {
try {
const response = await fetch(/* provider endpoint */, {
method: 'POST',
headers: { 'Authorization': `Bearer ${input.apiKey}` },
body: JSON.stringify({ /* minimal probe */ }),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) return { ok: false, error: `${response.status}: ${response.statusText}` };
return { ok: true, latencyMs: /* ... */ };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
}

Guarantees to respect:

  • No API key logging.
  • Strict timeout (30s default).
  • Error truncated to ~200 characters.
  • No stack trace in the returned error.

composition/addons/providers-tester-registry.ts
import { MyProviderTester } from '../../addons/<name>/src/tester.js';
testers.set('<manifest-id>', new MyProviderTester());

composition/addons/provider-manifests.ts
PROVIDER_MANIFESTS.push({
id: '<manifest-id>',
label: 'My Provider',
description: 'Short description.',
addonId: '<provider-name>',
models: [
{ id: 'model-fast', label: 'Fast model' },
{ id: 'model-pro', label: 'Pro model' },
],
authKind: 'apikey', // or 'oauth' / 'none'
apiKeyHint: 'https://docs.provider.com/api-keys',
capabilities: { streaming: true, vision: false, functions: true },
});

To allow chat (not just connection test):

// addons/<name>/src/runtime.ts
import type { ChatRuntime, StreamMessageInput, ChatStreamEvent } from '../../../core/ports/outbound/chat-runtime.js';
export class MyProviderRuntime implements ChatRuntime {
async *streamMessage(input: StreamMessageInput): AsyncIterable<ChatStreamEvent> {
// Map provider SSE → ChatStreamEvent (block-start / delta / block-end / done / error)
}
}

Wire into composition/addons/chat-runtime-registry.ts.


import { vi, describe, it, expect, beforeEach } from 'vitest';
import { MyProviderTester } from './tester.js';
describe('MyProviderTester', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
it('check refuses an empty key', async () => {
const tester = new MyProviderTester();
const result = await tester.test({ modelId: 'model-fast', kind: 'check' });
expect(result.ok).toBe(false);
expect(result.error).toContain('API key required');
});
it('live probe — success', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ /* payload */ }),
});
// ...
});
});

Cases to cover: check OK, empty key, 401, timeout, empty response, network error.


GuaranteeMechanism
Key never plain-text on diskAES-256-GCM in SQLite
Master key protected<arkaHome>/secrets/secrets.key, mode 600
Key never returned to UIPublic DTO exposes only apiKeyConfigured: boolean
No key loggingThe tester receives the decrypted key in ephemeral memory
Controlled external transmissionOnly the live test POSTs the key to the provider
Runtime URLLoopback + *.arkalabs.app HTTPS by default, external host refused (ADR 0006)

// ❌ NEVER
localStorage.setItem('provider-key', apiKey);
// ✅ Always send to the server, which encrypts + stores
await providersApi.createInstance({ name, manifestId, modelId, apiKey });
// ❌ may leak the key
logger.info('instance created', instance);
// ✅
logger.info('instance created', { id: instance.id, manifestId: instance.manifestId });
// ❌
const model = 'gemini-2.5-flash';
// ✅
const inst = await forProviderInstances.getById(instanceId);
const model = inst.modelId; // user-configurable via the UI

ActionFile
Instance domaincore/domain/providers/provider-instance.ts
AES-GCM cipheradapters/outbound/secrets/fs-secret-cipher.ts
CRUD use-casescore/use-cases/providers/build-for-provider-instances.ts
Server routesadapters/inbound/web/server/src/routes/providers.ts
Catalogue manifestscomposition/addons/provider-manifests.ts
Tester registrycomposition/addons/providers-tester-registry.ts
Runtime registry (chat)composition/addons/chat-runtime-registry.ts