Skip to content
Open
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
131 changes: 130 additions & 1 deletion src/__tests__/inference-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ describe("Static Model Baseline", () => {
});

it("all models have valid provider", () => {
const validProviders = ["openai", "anthropic", "conway", "other"];
const validProviders = ["openai", "anthropic", "conway", "opencode", "other"];
for (const model of STATIC_MODEL_BASELINE) {
expect(validProviders).toContain(model.provider);
}
Expand Down Expand Up @@ -950,6 +950,135 @@ describe("Inference DB Helpers", () => {
});
});

// ─── OpenCode Zen Provider Tests ─────────────────────────────────

describe("OpenCode Zen provider support", () => {
it("opencode models can be registered in model registry", () => {
const registry = new ModelRegistry(db);
registry.initialize();
const now = new Date().toISOString();
registry.upsert({
modelId: "gpt-5.6-sol",
provider: "opencode",
displayName: "GPT-5.6 Sol (via OpenCode Zen)",
tierMinimum: "normal",
costPer1kInput: 0,
costPer1kOutput: 0,
maxTokens: 4096,
contextWindow: 128000,
supportsTools: true,
supportsVision: true,
parameterStyle: "max_tokens",
enabled: true,
lastSeen: null,
createdAt: now,
updatedAt: now,
});
const entry = registry.get("gpt-5.6-sol");
expect(entry).toBeDefined();
expect(entry!.provider).toBe("opencode");
expect(entry!.enabled).toBe(true);
});

it("opencode models are not auto-disabled during baseline initialization", () => {
const registry = new ModelRegistry(db);
const now = new Date().toISOString();
// Pre-populate an opencode model before initialization
const row: ModelRegistryRow = {
modelId: "claude-sonnet-5",
provider: "opencode",
displayName: "Claude Sonnet 5 (via OpenCode Zen)",
tierMinimum: "normal",
costPer1kInput: 0,
costPer1kOutput: 0,
maxTokens: 4096,
contextWindow: 200000,
supportsTools: true,
supportsVision: true,
parameterStyle: "max_tokens",
enabled: true,
createdAt: now,
updatedAt: now,
};
modelRegistryUpsert(db, row);
// Initialize seeds baseline models - should NOT disable opencode models
registry.initialize();
const entry = registry.get("claude-sonnet-5");
expect(entry).toBeDefined();
expect(entry!.enabled).toBe(true);
});

it("refreshFromApi populates opencode models from proxy discovery", () => {
const registry = new ModelRegistry(db);
registry.initialize();
registry.refreshFromApi([
{
id: "deepseek-v4-flash",
provider: "opencode",
display_name: "DeepSeek V4 Flash",
max_tokens: 8192,
context_window: 131072,
supports_tools: true,
supports_vision: false,
},
{
id: "gemini-3.5-flash",
provider: "opencode",
display_name: "Gemini 3.5 Flash",
max_tokens: 4096,
context_window: 1048576,
supports_tools: true,
supports_vision: true,
},
]);
const deepseek = registry.get("deepseek-v4-flash");
expect(deepseek).toBeDefined();
expect(deepseek!.provider).toBe("opencode");
expect(deepseek!.contextWindow).toBe(131072);
const gemini = registry.get("gemini-3.5-flash");
expect(gemini).toBeDefined();
expect(gemini!.provider).toBe("opencode");
});

it("router selects opencode model when configured as fallback", () => {
const registry = new ModelRegistry(db);
registry.initialize();
const now = new Date().toISOString();
// Register an opencode model
registry.upsert({
modelId: "opencode/gpt-5.6-sol",
provider: "opencode",
displayName: "GPT-5.6 Sol via OpenCode Zen",
tierMinimum: "normal",
costPer1kInput: 0,
costPer1kOutput: 0,
maxTokens: 4096,
contextWindow: 128000,
supportsTools: true,
supportsVision: true,
parameterStyle: "max_tokens",
enabled: true,
lastSeen: null,
createdAt: now,
updatedAt: now,
});
const config: ModelStrategyConfig = {
...DEFAULT_MODEL_STRATEGY_CONFIG,
inferenceModel: "opencode/gpt-5.6-sol",
};
const budget = new InferenceBudgetTracker(db, config);
const router = new InferenceRouter(db, registry, budget);
// Disable all baseline models so fallback kicks in
for (const m of STATIC_MODEL_BASELINE) {
registry.setEnabled(m.modelId, false);
}
const selected = router.selectModel("normal", "agent_turn");
expect(selected).toBeDefined();
expect(selected!.modelId).toBe("opencode/gpt-5.6-sol");
expect(selected!.provider).toBe("opencode");
});
});

// ─── Default Model Strategy Config Tests ──────────────────────────

describe("DEFAULT_MODEL_STRATEGY_CONFIG", () => {
Expand Down
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ export function createConfig(params: {
openaiApiKey?: string;
anthropicApiKey?: string;
ollamaBaseUrl?: string;
opencodeBaseUrl?: string;
opencodeApiKey?: string;
parentAddress?: string;
treasuryPolicy?: TreasuryPolicy;
chainType?: ChainType;
Expand All @@ -144,6 +146,8 @@ export function createConfig(params: {
openaiApiKey: params.openaiApiKey,
anthropicApiKey: params.anthropicApiKey,
ollamaBaseUrl: params.ollamaBaseUrl,
opencodeBaseUrl: params.opencodeBaseUrl,
opencodeApiKey: params.opencodeApiKey,
inferenceModel: DEFAULT_CONFIG.inferenceModel || "gpt-5.2",
maxTokensPerTurn: DEFAULT_CONFIG.maxTokensPerTurn || 4096,
heartbeatConfigPath:
Expand Down
33 changes: 26 additions & 7 deletions src/conway/inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ interface InferenceClientOptions {
openaiApiKey?: string;
anthropicApiKey?: string;
ollamaBaseUrl?: string;
opencodeBaseUrl?: string;
opencodeApiKey?: string;
/** Optional registry lookup — if provided, used before name heuristics */
getModelProvider?: (modelId: string) => string | undefined;
}

type InferenceBackend = "conway" | "openai" | "anthropic" | "ollama";
type InferenceBackend = "conway" | "openai" | "anthropic" | "ollama" | "opencode";

function isLoopbackHttpUrl(url: string | undefined): boolean {
if (!url) return false;
Expand All @@ -48,11 +50,11 @@ function isLoopbackHttpUrl(url: string | undefined): boolean {
export function createInferenceClient(
options: InferenceClientOptions,
): InferenceClient {
const { apiUrl, apiKey, openaiApiKey, anthropicApiKey, ollamaBaseUrl, getModelProvider } = options;
const { apiUrl, apiKey, openaiApiKey, anthropicApiKey, ollamaBaseUrl, opencodeBaseUrl, opencodeApiKey, getModelProvider } = options;
const httpClient = new ResilientHttpClient({
baseTimeout: INFERENCE_TIMEOUT_MS,
retryableStatuses: [429, 500, 502, 503, 504],
allowHttpOnLoopback: isLoopbackHttpUrl(ollamaBaseUrl),
allowHttpOnLoopback: isLoopbackHttpUrl(ollamaBaseUrl) || isLoopbackHttpUrl(opencodeBaseUrl),
});
let currentModel = options.defaultModel;
let maxTokens = options.maxTokens;
Expand All @@ -68,13 +70,14 @@ export function createInferenceClient(
openaiApiKey,
anthropicApiKey,
ollamaBaseUrl,
opencodeBaseUrl,
getModelProvider,
});

// Newer models (o-series, gpt-5.x, gpt-4.1) require max_completion_tokens.
// Ollama always uses max_tokens.
// Ollama and OpenCode always use max_tokens.
const usesCompletionTokens =
backend !== "ollama" && /^(o[1-9]|gpt-5|gpt-4\.1)/.test(model);
backend !== "ollama" && backend !== "opencode" && /^(o[1-9]|gpt-5|gpt-4\.1)/.test(model);
const tokenLimit = opts?.maxTokens || maxTokens;

const body: Record<string, unknown> = {
Expand Down Expand Up @@ -111,10 +114,12 @@ export function createInferenceClient(
}

const openAiLikeApiUrl =
backend === "opencode" ? (opencodeBaseUrl as string).replace(/\/$/, "") :
backend === "openai" ? "https://api.openai.com" :
backend === "ollama" ? (ollamaBaseUrl as string).replace(/\/$/, "") :
apiUrl;
const openAiLikeApiKey =
backend === "opencode" ? (opencodeApiKey || "") :
backend === "openai" ? (openaiApiKey as string) :
backend === "ollama" ? "ollama" :
apiKey;
Expand Down Expand Up @@ -180,12 +185,14 @@ function resolveInferenceBackend(
openaiApiKey?: string;
anthropicApiKey?: string;
ollamaBaseUrl?: string;
opencodeBaseUrl?: string;
getModelProvider?: (modelId: string) => string | undefined;
},
): InferenceBackend {
// Registry-based routing: most accurate, no name guessing
if (keys.getModelProvider) {
const provider = keys.getModelProvider(model);
if (provider === "opencode" && keys.opencodeBaseUrl) return "opencode";
if (provider === "ollama" && keys.ollamaBaseUrl) return "ollama";
if (provider === "anthropic" && keys.anthropicApiKey) return "anthropic";
if (provider === "openai" && keys.openaiApiKey) return "openai";
Expand All @@ -205,15 +212,15 @@ async function chatViaOpenAiCompatible(params: {
body: Record<string, unknown>;
apiUrl: string;
apiKey: string;
backend: "conway" | "openai" | "ollama";
backend: "conway" | "openai" | "ollama" | "opencode";
httpClient: ResilientHttpClient;
}): Promise<InferenceResponse> {
const resp = await params.httpClient.request(`${params.apiUrl}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization:
params.backend === "openai" || params.backend === "ollama"
params.backend === "openai" || params.backend === "ollama" || params.backend === "opencode"
? `Bearer ${params.apiKey}`
: params.apiKey,
},
Expand All @@ -223,6 +230,18 @@ async function chatViaOpenAiCompatible(params: {

if (!resp.ok) {
const text = await resp.text();
if (params.backend === "opencode") {
switch (resp.status) {
case 401:
throw new Error(`Invalid OpenCode API key: ${text}`);
case 429:
throw new Error(`Rate limited by OpenCode proxy: ${text}`);
case 404:
throw new Error(`Model not found on OpenCode Zen: ${text}`);
default:
throw new Error(`OpenCode Zen error ${resp.status}: ${text}`);
}
}
throw new Error(
`Inference error (${params.backend}): ${resp.status}: ${text}`,
);
Expand Down
36 changes: 36 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ Environment:
CONWAY_API_URL Conway API URL (default: https://api.conway.tech)
CONWAY_API_KEY Conway API key (overrides config)
OLLAMA_BASE_URL Ollama base URL (overrides config, e.g. http://localhost:11434)
OPENCODE_BASE_URL OpenCode Zen base URL (overrides config)
OPENCODE_API_KEY OpenCode Zen API key (overrides config)
`);
process.exit(0);
}
Expand Down Expand Up @@ -280,10 +282,39 @@ async function run(): Promise<void> {
// Resolve Ollama base URL: env var takes precedence over config
const ollamaBaseUrl = process.env.OLLAMA_BASE_URL || config.ollamaBaseUrl;

// Resolve OpenCode Zen base URL and API key: env vars take precedence over config
const opencodeBaseUrl = process.env.OPENCODE_BASE_URL || config.opencodeBaseUrl;
const opencodeApiKey = process.env.OPENCODE_API_KEY || config.opencodeApiKey;

// Create inference client — pass a live registry lookup so model names like
// "gpt-oss:120b" route to Ollama based on their registered provider, not heuristics.
const modelRegistry = new ModelRegistry(db.raw);
modelRegistry.initialize();

// Auto-discover models from OpenCode Zen proxy
if (opencodeBaseUrl && opencodeApiKey) {
try {
const modelsUrl = `${opencodeBaseUrl.replace(/\/$/, "")}/v1/models`;
const resp = await fetch(modelsUrl, {
headers: opencodeApiKey ? { Authorization: `Bearer ${opencodeApiKey}` } : {},
signal: AbortSignal.timeout(10_000),
});
if (resp.ok) {
const data = await resp.json() as any;
const models = (data.data || []).map((m: any) => ({
...m,
provider: "opencode",
}));
modelRegistry.refreshFromApi(models);
logger.info(`[${new Date().toISOString()}] OpenCode Zen: discovered ${models.length} models from ${modelsUrl}`);
} else {
logger.warn(`[${new Date().toISOString()}] OpenCode Zen model discovery failed: ${resp.status}`);
}
} catch (err: any) {
logger.warn(`[${new Date().toISOString()}] OpenCode Zen model discovery error: ${err.message}`);
}
}

const inference = createInferenceClient({
apiUrl: config.conwayApiUrl,
apiKey,
Expand All @@ -293,12 +324,17 @@ async function run(): Promise<void> {
openaiApiKey: config.openaiApiKey,
anthropicApiKey: config.anthropicApiKey,
ollamaBaseUrl,
opencodeBaseUrl,
opencodeApiKey,
getModelProvider: (modelId) => modelRegistry.get(modelId)?.provider,
});

if (ollamaBaseUrl) {
logger.info(`[${new Date().toISOString()}] Ollama backend: ${ollamaBaseUrl}`);
}
if (opencodeBaseUrl && opencodeApiKey) {
logger.info(`[${new Date().toISOString()}] OpenCode Zen backend: ${opencodeBaseUrl}`);
}

// Create social client (chain-aware: pass ChainIdentity for Solana signing)
let social: SocialClientInterface | undefined;
Expand Down
1 change: 1 addition & 0 deletions src/inference/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export class ModelRegistry {
!baselineIds.has(existing.modelId) &&
existing.enabled &&
existing.provider !== "ollama" &&
existing.provider !== "opencode" &&
existing.provider !== "other"
) {
modelRegistrySetEnabled(this.db, existing.modelId, false);
Expand Down
4 changes: 3 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface AutomatonConfig {
openaiApiKey?: string;
anthropicApiKey?: string;
ollamaBaseUrl?: string;
opencodeBaseUrl?: string;
opencodeApiKey?: string;
inferenceModel: string;
maxTokensPerTurn: number;
heartbeatConfigPath: string;
Expand Down Expand Up @@ -1142,7 +1144,7 @@ export const DEFAULT_MEMORY_BUDGET: MemoryBudget = {

// === Phase 2.3: Inference & Model Strategy Types ===

export type ModelProvider = "openai" | "anthropic" | "conway" | "ollama" | "other";
export type ModelProvider = "openai" | "anthropic" | "conway" | "ollama" | "opencode" | "other";

export type InferenceTaskType =
| "agent_turn"
Expand Down