feat(providers): add Codex and Ollama provider runners with message adapters

This commit is contained in:
Hibryda 2026-03-11 03:56:05 +01:00
parent 4ae7ca6634
commit 3e34fda59a
9 changed files with 985 additions and 2 deletions

View file

@ -7,6 +7,8 @@
import { startHealthTick, stopHealthTick, clearHealthTracking } from './lib/stores/health.svelte';
import { registerProvider } from './lib/providers/registry.svelte';
import { CLAUDE_PROVIDER } from './lib/providers/claude';
import { CODEX_PROVIDER } from './lib/providers/codex';
import { OLLAMA_PROVIDER } from './lib/providers/ollama';
import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte';
// Workspace components
@ -68,6 +70,8 @@
if (v) document.documentElement.style.setProperty('--project-max-aspect', v);
});
registerProvider(CLAUDE_PROVIDER);
registerProvider(CODEX_PROVIDER);
registerProvider(OLLAMA_PROVIDER);
startAgentDispatcher();
startHealthTick();

View file

@ -0,0 +1,297 @@
// Codex Message Adapter — transforms Codex CLI NDJSON events to internal AgentMessage format
// Codex events: thread.started, turn.started, item.started/updated/completed, turn.completed/failed
import type {
AgentMessage,
InitContent,
TextContent,
ThinkingContent,
ToolCallContent,
ToolResultContent,
StatusContent,
CostContent,
ErrorContent,
} from './claude-messages';
function str(v: unknown, fallback = ''): string {
return typeof v === 'string' ? v : fallback;
}
function num(v: unknown, fallback = 0): number {
return typeof v === 'number' ? v : fallback;
}
export function adaptCodexMessage(raw: Record<string, unknown>): AgentMessage[] {
const timestamp = Date.now();
const uuid = crypto.randomUUID();
switch (raw.type) {
case 'thread.started':
return [{
id: uuid,
type: 'init',
content: {
sessionId: str(raw.thread_id),
model: '',
cwd: '',
tools: [],
} satisfies InitContent,
timestamp,
}];
case 'turn.started':
return [{
id: uuid,
type: 'status',
content: { subtype: 'turn_started' } satisfies StatusContent,
timestamp,
}];
case 'turn.completed':
return adaptTurnCompleted(raw, uuid, timestamp);
case 'turn.failed':
return [{
id: uuid,
type: 'error',
content: {
message: str((raw.error as Record<string, unknown>)?.message, 'Turn failed'),
} satisfies ErrorContent,
timestamp,
}];
case 'item.started':
case 'item.updated':
case 'item.completed':
return adaptItem(raw, uuid, timestamp);
case 'error':
return [{
id: uuid,
type: 'error',
content: { message: str(raw.message, 'Unknown error') } satisfies ErrorContent,
timestamp,
}];
default:
return [{
id: uuid,
type: 'unknown',
content: raw,
timestamp,
}];
}
}
function adaptTurnCompleted(
raw: Record<string, unknown>,
uuid: string,
timestamp: number,
): AgentMessage[] {
const usage = typeof raw.usage === 'object' && raw.usage !== null
? raw.usage as Record<string, unknown>
: {};
return [{
id: uuid,
type: 'cost',
content: {
totalCostUsd: 0,
durationMs: 0,
inputTokens: num(usage.input_tokens),
outputTokens: num(usage.output_tokens),
numTurns: 1,
isError: false,
} satisfies CostContent,
timestamp,
}];
}
function adaptItem(
raw: Record<string, unknown>,
uuid: string,
timestamp: number,
): AgentMessage[] {
const item = typeof raw.item === 'object' && raw.item !== null
? raw.item as Record<string, unknown>
: {};
const itemType = str(item.type);
const eventType = str(raw.type);
switch (itemType) {
case 'agent_message':
if (eventType !== 'item.completed') return [];
return [{
id: uuid,
type: 'text',
content: { text: str(item.text) } satisfies TextContent,
timestamp,
}];
case 'reasoning':
if (eventType !== 'item.completed') return [];
return [{
id: uuid,
type: 'thinking',
content: { text: str(item.text) } satisfies ThinkingContent,
timestamp,
}];
case 'command_execution':
return adaptCommandExecution(item, uuid, timestamp, eventType);
case 'file_change':
return adaptFileChange(item, uuid, timestamp, eventType);
case 'mcp_tool_call':
return adaptMcpToolCall(item, uuid, timestamp, eventType);
case 'web_search':
if (eventType !== 'item.completed') return [];
return [{
id: uuid,
type: 'tool_call',
content: {
toolUseId: str(item.id, uuid),
name: 'WebSearch',
input: { query: str(item.query) },
} satisfies ToolCallContent,
timestamp,
}];
case 'error':
return [{
id: uuid,
type: 'error',
content: { message: str(item.message, 'Item error') } satisfies ErrorContent,
timestamp,
}];
default:
return [];
}
}
function adaptCommandExecution(
item: Record<string, unknown>,
uuid: string,
timestamp: number,
eventType: string,
): AgentMessage[] {
const messages: AgentMessage[] = [];
const toolUseId = str(item.id, uuid);
if (eventType === 'item.started' || eventType === 'item.completed') {
messages.push({
id: `${uuid}-call`,
type: 'tool_call',
content: {
toolUseId,
name: 'Bash',
input: { command: str(item.command) },
} satisfies ToolCallContent,
timestamp,
});
}
if (eventType === 'item.completed') {
messages.push({
id: `${uuid}-result`,
type: 'tool_result',
content: {
toolUseId,
output: str(item.aggregated_output),
} satisfies ToolResultContent,
timestamp,
});
}
return messages;
}
function adaptFileChange(
item: Record<string, unknown>,
uuid: string,
timestamp: number,
eventType: string,
): AgentMessage[] {
if (eventType !== 'item.completed') return [];
const changes = Array.isArray(item.changes) ? item.changes as Array<Record<string, unknown>> : [];
if (changes.length === 0) return [];
const messages: AgentMessage[] = [];
for (const change of changes) {
const kind = str(change.kind);
const toolName = kind === 'delete' ? 'Bash' : kind === 'add' ? 'Write' : 'Edit';
const toolUseId = `${uuid}-${str(change.path)}`;
messages.push({
id: `${toolUseId}-call`,
type: 'tool_call',
content: {
toolUseId,
name: toolName,
input: { file_path: str(change.path) },
} satisfies ToolCallContent,
timestamp,
});
messages.push({
id: `${toolUseId}-result`,
type: 'tool_result',
content: {
toolUseId,
output: `File ${kind}: ${str(change.path)}`,
} satisfies ToolResultContent,
timestamp,
});
}
return messages;
}
function adaptMcpToolCall(
item: Record<string, unknown>,
uuid: string,
timestamp: number,
eventType: string,
): AgentMessage[] {
const messages: AgentMessage[] = [];
const toolUseId = str(item.id, uuid);
const toolName = `${str(item.server)}:${str(item.tool)}`;
if (eventType === 'item.started' || eventType === 'item.completed') {
messages.push({
id: `${uuid}-call`,
type: 'tool_call',
content: {
toolUseId,
name: toolName,
input: item.arguments,
} satisfies ToolCallContent,
timestamp,
});
}
if (eventType === 'item.completed') {
const result = typeof item.result === 'object' && item.result !== null
? item.result as Record<string, unknown>
: undefined;
const error = typeof item.error === 'object' && item.error !== null
? item.error as Record<string, unknown>
: undefined;
messages.push({
id: `${uuid}-result`,
type: 'tool_result',
content: {
toolUseId,
output: error ? str(error.message, 'MCP tool error') : (result?.content ?? result?.structured_content ?? 'OK'),
} satisfies ToolResultContent,
timestamp,
});
}
return messages;
}

View file

@ -4,6 +4,8 @@
import type { AgentMessage } from './claude-messages';
import type { ProviderId } from '../providers/types';
import { adaptSDKMessage } from './claude-messages';
import { adaptCodexMessage } from './codex-messages';
import { adaptOllamaMessage } from './ollama-messages';
/** Function signature for a provider message adapter */
export type MessageAdapter = (raw: Record<string, unknown>) => AgentMessage[];
@ -25,5 +27,7 @@ export function adaptMessage(providerId: ProviderId, raw: Record<string, unknown
return adapter(raw);
}
// Register Claude adapter by default
// Register all provider adapters
registerMessageAdapter('claude', adaptSDKMessage);
registerMessageAdapter('codex', adaptCodexMessage);
registerMessageAdapter('ollama', adaptOllamaMessage);

View file

@ -0,0 +1,147 @@
// Ollama Message Adapter — transforms Ollama chat streaming events to internal AgentMessage format
// Ollama runner emits synthesized events wrapping /api/chat NDJSON chunks
import type {
AgentMessage,
InitContent,
TextContent,
ThinkingContent,
StatusContent,
CostContent,
ErrorContent,
} from './claude-messages';
function str(v: unknown, fallback = ''): string {
return typeof v === 'string' ? v : fallback;
}
function num(v: unknown, fallback = 0): number {
return typeof v === 'number' ? v : fallback;
}
/**
* Adapt a raw Ollama runner event to AgentMessage[].
*
* The Ollama runner emits events in this format:
* - {type:'system', subtype:'init', model, ...}
* - {type:'chunk', message:{role,content,thinking}, done:false}
* - {type:'chunk', message:{role,content}, done:true, done_reason, prompt_eval_count, eval_count, ...}
* - {type:'error', message:'...'}
*/
export function adaptOllamaMessage(raw: Record<string, unknown>): AgentMessage[] {
const timestamp = Date.now();
const uuid = crypto.randomUUID();
switch (raw.type) {
case 'system':
return adaptSystemEvent(raw, uuid, timestamp);
case 'chunk':
return adaptChunk(raw, uuid, timestamp);
case 'error':
return [{
id: uuid,
type: 'error',
content: { message: str(raw.message, 'Ollama error') } satisfies ErrorContent,
timestamp,
}];
default:
return [{
id: uuid,
type: 'unknown',
content: raw,
timestamp,
}];
}
}
function adaptSystemEvent(
raw: Record<string, unknown>,
uuid: string,
timestamp: number,
): AgentMessage[] {
const subtype = str(raw.subtype);
if (subtype === 'init') {
return [{
id: uuid,
type: 'init',
content: {
sessionId: str(raw.session_id),
model: str(raw.model),
cwd: str(raw.cwd),
tools: [],
} satisfies InitContent,
timestamp,
}];
}
return [{
id: uuid,
type: 'status',
content: {
subtype,
message: typeof raw.status === 'string' ? raw.status : undefined,
} satisfies StatusContent,
timestamp,
}];
}
function adaptChunk(
raw: Record<string, unknown>,
uuid: string,
timestamp: number,
): AgentMessage[] {
const messages: AgentMessage[] = [];
const msg = typeof raw.message === 'object' && raw.message !== null
? raw.message as Record<string, unknown>
: {};
const done = raw.done === true;
// Thinking content (extended thinking from Qwen3 etc.)
const thinking = str(msg.thinking);
if (thinking) {
messages.push({
id: `${uuid}-think`,
type: 'thinking',
content: { text: thinking } satisfies ThinkingContent,
timestamp,
});
}
// Text content
const text = str(msg.content);
if (text) {
messages.push({
id: `${uuid}-text`,
type: 'text',
content: { text } satisfies TextContent,
timestamp,
});
}
// Final chunk with token counts
if (done) {
const doneReason = str(raw.done_reason);
const evalDuration = num(raw.eval_duration);
const durationMs = evalDuration > 0 ? Math.round(evalDuration / 1_000_000) : 0;
messages.push({
id: `${uuid}-cost`,
type: 'cost',
content: {
totalCostUsd: 0,
durationMs,
inputTokens: num(raw.prompt_eval_count),
outputTokens: num(raw.eval_count),
numTurns: 1,
isError: doneReason === 'error',
} satisfies CostContent,
timestamp,
});
}
return messages;
}

View file

@ -0,0 +1,20 @@
// Codex Provider — metadata and capabilities for OpenAI Codex CLI
import type { ProviderMeta } from './types';
export const CODEX_PROVIDER: ProviderMeta = {
id: 'codex',
name: 'Codex CLI',
description: 'OpenAI Codex CLI agent via SDK',
capabilities: {
hasProfiles: false,
hasSkills: false,
hasModelSelection: true,
hasSandbox: true,
supportsSubagents: false,
supportsCost: false,
supportsResume: true,
},
sidecarRunner: 'codex-runner.mjs',
defaultModel: 'gpt-5.4',
};

View file

@ -0,0 +1,20 @@
// Ollama Provider — metadata and capabilities for local Ollama models
import type { ProviderMeta } from './types';
export const OLLAMA_PROVIDER: ProviderMeta = {
id: 'ollama',
name: 'Ollama',
description: 'Local Ollama models via REST API',
capabilities: {
hasProfiles: false,
hasSkills: false,
hasModelSelection: true,
hasSandbox: false,
supportsSubagents: false,
supportsCost: false,
supportsResume: false,
},
sidecarRunner: 'ollama-runner.mjs',
defaultModel: 'qwen3:8b',
};