From 1d1fadeab3829902f7829a61c89521297a0b8a10 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 10:27:35 +0000 Subject: [PATCH] feat(ai-core): opt-in c0mpute provider c0mpute (Infernet's OpenAI-compatible endpoint) as a third provider kind. Not a default: transcripts are private and peer nodes are third parties, so the factory keeps requiring an explicit kind. Co-Authored-By: Claude Opus 5.5 --- packages/ai-core/README.md | 2 +- packages/ai-core/src/config.ts | 4 + packages/ai-core/src/index.ts | 1 + packages/ai-core/src/providers/c0mpute.ts | 106 ++++++++++++++++++ packages/ai-core/src/providers/factory.ts | 12 +- .../ai-core/src/providers/providers.test.ts | 37 ++++++ packages/ai-core/src/types.ts | 2 +- 7 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 packages/ai-core/src/providers/c0mpute.ts diff --git a/packages/ai-core/README.md b/packages/ai-core/README.md index acb9496a..b8a0b727 100644 --- a/packages/ai-core/README.md +++ b/packages/ai-core/README.md @@ -7,7 +7,7 @@ structured output out. ## What it does - **One interface** — `AiProvider` with `summarizeSession()` and `selectClips()`. -- **Two drivers** — `anthropic` (managed key **or** BYOK) and `ollama` (fully local). +- **Three drivers** — `anthropic` (managed key **or** BYOK), `ollama` (fully local) and `c0mpute` (Infernet peer network, OpenAI-compatible). `c0mpute` is opt-in only: a transcript is the users' own conversation and peer nodes are third parties, so nothing selects it by default. - **Validated output** — every reply is parsed against Zod schemas (`SessionNote`, `ClipCandidate[]`). Malformed output triggers exactly one auto-repair retry, then throws `StructuredOutputError` so the caller can fall back to manual mode — the diff --git a/packages/ai-core/src/config.ts b/packages/ai-core/src/config.ts index 46df4478..1c49261d 100644 --- a/packages/ai-core/src/config.ts +++ b/packages/ai-core/src/config.ts @@ -10,5 +10,9 @@ export const DEFAULT_ANTHROPIC_MODEL = 'claude-haiku-4-5'; export const DEFAULT_OLLAMA_MODEL = 'llama3.1'; export const DEFAULT_OLLAMA_BASE_URL = 'http://127.0.0.1:11434'; +/** Infernet's OpenAI-compatible endpoint for c0mpute (opt-in; see providers/c0mpute.ts). */ +export const DEFAULT_C0MPUTE_BASE_URL = 'https://infernetprotocol.com/v1'; +/** The Infernet house model every node pulls. */ +export const DEFAULT_C0MPUTE_MODEL = 'huihui_ai/qwen3.5-abliterated:9b'; export const DEFAULT_MAX_TOKENS = 2048; export const DEFAULT_MAX_CLIPS = 6; diff --git a/packages/ai-core/src/index.ts b/packages/ai-core/src/index.ts index 67bf6fb4..26c599de 100644 --- a/packages/ai-core/src/index.ts +++ b/packages/ai-core/src/index.ts @@ -17,4 +17,5 @@ export * from './complete.js'; export * from './prompts.js'; export * from './providers/anthropic.js'; export * from './providers/ollama.js'; +export * from './providers/c0mpute.js'; export * from './providers/factory.js'; diff --git a/packages/ai-core/src/providers/c0mpute.ts b/packages/ai-core/src/providers/c0mpute.ts new file mode 100644 index 00000000..6fd1b574 --- /dev/null +++ b/packages/ai-core/src/providers/c0mpute.ts @@ -0,0 +1,106 @@ +import { z } from 'zod'; +import { completeStructured, type CompleteFn } from '../complete.js'; +import { DEFAULT_C0MPUTE_BASE_URL, DEFAULT_C0MPUTE_MODEL, DEFAULT_MAX_TOKENS } from '../config.js'; +import { buildClipPrompt, buildSummaryPrompt } from '../prompts.js'; +import { + clipCandidatesSchema, + sessionNoteSchema, + type ClipCandidate, + type SessionNote, +} from '../schemas.js'; +import type { + AiProvider, + SelectClipsOptions, + SummarizeOptions, + TranscriptInput, +} from '../types.js'; +import type { FetchLike } from './ollama.js'; + +const chatCompletionSchema = z.object({ + choices: z.array(z.object({ message: z.object({ content: z.string().nullable() }) })).min(1), +}); + +export interface C0mputeProviderOptions { + /** Infernet account key (ik_live_...). Without one, the free tier (10 calls a day). */ + apiKey?: string; + model?: string; + baseUrl?: string; + maxTokens?: number; + fetchImpl?: FetchLike; +} + +const defaultFetch: FetchLike = (url, init) => + (globalThis as unknown as { fetch: FetchLike }).fetch(url, init); + +function stripTrailingSlashes(url: string): string { + let end = url.length; + while (end > 0 && url[end - 1] === '/') { + end -= 1; + } + return url.slice(0, end); +} + +/** + * c0mpute: the Infernet peer network, through its OpenAI-compatible endpoint. + * + * OPT-IN ONLY. A session transcript is the users' own conversation, and on + * c0mpute it is processed by third-party peer nodes. Nothing in this package + * selects this provider by default; a caller (or a user setting) must choose + * it explicitly with `kind: 'c0mpute'`. Peer nodes are slow under load (often + * 60-240 s a call), so do not run it on a request path. + */ +export function createC0mputeProvider(options: C0mputeProviderOptions = {}): AiProvider { + const model = options.model ?? DEFAULT_C0MPUTE_MODEL; + const baseUrl = stripTrailingSlashes(options.baseUrl ?? DEFAULT_C0MPUTE_BASE_URL); + const fetchImpl = options.fetchImpl ?? defaultFetch; + const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS; + + const complete: CompleteFn = async ({ system, user }) => { + const headers: Record = { 'content-type': 'application/json' }; + if (options.apiKey) { + headers.authorization = `Bearer ${options.apiKey}`; + } + const response = await fetchImpl(`${baseUrl}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + }), + }); + if (!response.ok) { + throw new Error(`c0mpute request failed: ${String(response.status)} ${response.statusText}`); + } + const data = chatCompletionSchema.parse(await response.json()); + return data.choices[0]?.message.content ?? ''; + }; + + return { + kind: 'c0mpute', + model, + summarizeSession( + input: TranscriptInput, + summarizeOptions?: SummarizeOptions + ): Promise { + return completeStructured( + complete, + buildSummaryPrompt(input, summarizeOptions), + sessionNoteSchema + ); + }, + selectClips( + input: TranscriptInput, + selectOptions?: SelectClipsOptions + ): Promise { + return completeStructured( + complete, + buildClipPrompt(input, selectOptions), + clipCandidatesSchema + ); + }, + }; +} diff --git a/packages/ai-core/src/providers/factory.ts b/packages/ai-core/src/providers/factory.ts index 6e597ac2..121b8e68 100644 --- a/packages/ai-core/src/providers/factory.ts +++ b/packages/ai-core/src/providers/factory.ts @@ -1,18 +1,26 @@ import type { AiProvider } from '../types.js'; import { createAnthropicProvider, type AnthropicProviderOptions } from './anthropic.js'; import { createOllamaProvider, type OllamaProviderOptions } from './ollama.js'; +import { createC0mputeProvider, type C0mputeProviderOptions } from './c0mpute.js'; /** Discriminated config for {@link createProvider}. */ export type AiProviderConfig = | ({ readonly kind: 'anthropic' } & AnthropicProviderOptions) - | ({ readonly kind: 'ollama' } & OllamaProviderOptions); + | ({ readonly kind: 'ollama' } & OllamaProviderOptions) + | ({ readonly kind: 'c0mpute' } & C0mputeProviderOptions); -/** Build the configured provider. `anthropic` covers both managed keys and BYOK. */ +/** + * Build the configured provider. `anthropic` covers both managed keys and BYOK. + * There is deliberately no default: transcripts are private, and which service + * reads them (including c0mpute's peer nodes) is an explicit choice. + */ export function createProvider(config: AiProviderConfig): AiProvider { switch (config.kind) { case 'anthropic': return createAnthropicProvider(config); case 'ollama': return createOllamaProvider(config); + case 'c0mpute': + return createC0mputeProvider(config); } } diff --git a/packages/ai-core/src/providers/providers.test.ts b/packages/ai-core/src/providers/providers.test.ts index 6553edf2..5bc990cb 100644 --- a/packages/ai-core/src/providers/providers.test.ts +++ b/packages/ai-core/src/providers/providers.test.ts @@ -125,3 +125,40 @@ describe('createProvider', () => { expect(createProvider({ kind: 'ollama', fetchImpl: vi.fn() }).kind).toBe('ollama'); }); }); + +describe('c0mpute provider (opt-in)', () => { + it('posts to the OpenAI-compatible endpoint with the key and parses the reply', async () => { + const calls: { url: string; init: { headers: Record; body: string } }[] = []; + const fetchImpl: FetchLike = (url, init) => { + calls.push({ url, init }); + const res: FetchResponseLike = { + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve({ choices: [{ message: { content: NOTE_JSON } }] }), + }; + return Promise.resolve(res); + }; + const provider = createProvider({ kind: 'c0mpute', apiKey: 'ik_live_x', fetchImpl }); + expect(provider.kind).toBe('c0mpute'); + expect(provider.model).toBe('huihui_ai/qwen3.5-abliterated:9b'); + const note = await provider.summarizeSession(input); + expect(note.topics).toEqual(['release']); + expect(calls[0]?.url).toBe('https://infernetprotocol.com/v1/chat/completions'); + expect(calls[0]?.init.headers.authorization).toBe('Bearer ik_live_x'); + expect(calls[0]?.init.body).toContain('ship it'); + }); + + it('surfaces an HTTP failure', async () => { + const fetchImpl: FetchLike = () => + Promise.resolve({ + ok: false, + status: 503, + statusText: 'no node', + json: () => Promise.resolve({}), + }); + await expect(createProvider({ kind: 'c0mpute', fetchImpl }).selectClips(input)).rejects.toThrow( + /503/ + ); + }); +}); diff --git a/packages/ai-core/src/types.ts b/packages/ai-core/src/types.ts index 7daefa85..ee227cad 100644 --- a/packages/ai-core/src/types.ts +++ b/packages/ai-core/src/types.ts @@ -27,7 +27,7 @@ export interface TranscriptInput { durationMs?: number; } -export type ProviderKind = 'anthropic' | 'ollama'; +export type ProviderKind = 'anthropic' | 'ollama' | 'c0mpute'; export interface SummarizeOptions { template?: SessionTemplateId;