Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion packages/ai-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/ai-core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions packages/ai-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
106 changes: 106 additions & 0 deletions packages/ai-core/src/providers/c0mpute.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { '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<SessionNote> {
return completeStructured(
complete,
buildSummaryPrompt(input, summarizeOptions),
sessionNoteSchema
);
},
selectClips(
input: TranscriptInput,
selectOptions?: SelectClipsOptions
): Promise<ClipCandidate[]> {
return completeStructured(
complete,
buildClipPrompt(input, selectOptions),
clipCandidatesSchema
);
},
};
}
12 changes: 10 additions & 2 deletions packages/ai-core/src/providers/factory.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
37 changes: 37 additions & 0 deletions packages/ai-core/src/providers/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; 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/
);
});
});
2 changes: 1 addition & 1 deletion packages/ai-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading