Aller au contenu

Écrire un provider IA

Un provider IA dans arka-deck c’est :

  • Un manifest (métadonnées statiques : label, modèles, type d’authentification)
  • Une instance (configuration utilisateur : clé API, modèle, baseUrl, default) persistée dans <arkaHome>/providers/providers.db
  • Un ProviderTester (vérification connexion : check rapide ou live probe)
  • Un ChatRuntime (stream de chat agentique)
  • Optionnellement un LlmInvoker pour les workers 1-shot

Les clés API sont chiffrées AES-256-GCM côté serveur, jamais exposées à l’UI ni loggées.


addons/<provider-name>/
manifest.json ← kind: provider, status: draft
src/
index.ts ← exports tester + runtime
tester.ts ← impl ProviderTester
tester.test.ts
runtime.ts ← impl ChatRuntime
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: 'Clé API requise' };
return { ok: true };
}
private async runLive(input: ProviderTesterInput): Promise<TestResult> {
try {
const response = await fetch(/* endpoint provider */, {
method: 'POST',
headers: { 'Authorization': `Bearer ${input.apiKey}` },
body: JSON.stringify({ /* probe minimal */ }),
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) };
}
}
}

Garanties à respecter :

  • Pas de log de la clé API.
  • Timeout strict (30s par défaut).
  • Erreur tronquée à ~200 caractères.
  • Pas de stack trace dans le error retourné.

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: 'Mon Provider',
description: 'Description courte.',
addonId: '<provider-name>',
models: [
{ id: 'model-fast', label: 'Modèle rapide' },
{ id: 'model-pro', label: 'Modèle complet' },
],
authKind: 'apikey', // ou 'oauth' / 'none'
apiKeyHint: 'https://docs.provider.com/api-keys', // URL aide création clé
capabilities: { streaming: true, vision: false, functions: true },
});

Pour permettre le chat (pas seulement le test connexion) :

// 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> {
// Mapper SSE provider → ChatStreamEvent (block-start / delta / block-end / done / error)
}
}

Câbler dans 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 refuse une clé vide', async () => {
const tester = new MyProviderTester();
const result = await tester.test({ modelId: 'model-fast', kind: 'check' });
expect(result.ok).toBe(false);
expect(result.error).toContain('Clé API requise');
});
it('live probe — succès', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ /* payload */ }),
});
// ...
});
});

Cas à couvrir : check OK, clé vide, 401, timeout, réponse vide, erreur réseau.


GarantieMécanisme
Clé jamais en clair sur disqueAES-256-GCM dans SQLite
Clé maître protégée<arkaHome>/secrets/secrets.key, mode 600
Clé jamais retournée à l’UIDTO public expose apiKeyConfigured: boolean seulement
Pas de log cléTester reçoit la clé déchiffrée en mémoire éphémère
Transmission externe contrôléeSeul le live test POST la clé au provider
URL runtimeLoopback + *.arkalabs.app HTTPS par défaut, host externe refusé (ADR 0006)

// ❌ JAMAIS
localStorage.setItem('provider-key', apiKey);
// ✅ Toujours envoyer au serveur, qui chiffre + stocke
await providersApi.createInstance({ name, manifestId, modelId, apiKey });
// ❌ peut leaker la clé
logger.info('instance créée', instance);
// ✅
logger.info('instance créée', { 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 l'UI

ActionFichier
Domaine instancecore/domain/providers/provider-instance.ts
Cipher AES-GCMadapters/outbound/secrets/fs-secret-cipher.ts
Use-cases CRUDcore/use-cases/providers/build-for-provider-instances.ts
Routes serveuradapters/inbound/web/server/src/routes/providers.ts
Manifests cataloguecomposition/addons/provider-manifests.ts
Tester registrycomposition/addons/providers-tester-registry.ts
Runtime registry (chat)composition/addons/chat-runtime-registry.ts