Skip to content
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ GOOGLE_API_KEY=your_key_here

# Memory is embedded in the OpenRouter process (no separate service).
# MEMORY_DB_PATH=./memory.db # SQLite path for memory store (used by OpenRouter)

# Optional x402 passthrough for OpenRouter access
X402_BASE_URL=x402_supported_provider_url
PRIVATE_KEY=
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ Thumbs.db
*.tmp
*.temp

# Key and token storage
.ekai/

# AI configurations
CLAUDE.md
AGENTS.md
Expand Down
95 changes: 95 additions & 0 deletions gateway/src/app/handlers/key-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Request, Response } from 'express';
import { addKey, removeKey, getAllKeys, updateKeyPriority, maskKey } from '../../infrastructure/auth/key-store.js';
import { logger } from '../../infrastructure/utils/logger.js';

const VALID_PROVIDERS = ['anthropic', 'openai', 'openrouter', 'xai', 'zai', 'google', 'ollama'];

export async function handleListKeys(req: Request, res: Response): Promise<void> {
try {
const keys = getAllKeys().map(k => ({
id: k.id,
provider: k.provider,
label: k.label,
maskedKey: maskKey(k.key),
priority: k.priority,
source: k.source,
addedAt: k.addedAt,
}));
res.json({ keys });
} catch (error) {
logger.error('Failed to list keys', error, { module: 'key-handler' });
res.status(500).json({ error: 'Failed to list keys' });
}
}

export async function handleAddKey(req: Request, res: Response): Promise<void> {
try {
const { provider, key, label, priority } = req.body;

if (!provider || !key) {
res.status(400).json({ error: 'provider and key are required' });
return;
}

if (!VALID_PROVIDERS.includes(provider)) {
res.status(400).json({ error: `Invalid provider. Use one of: ${VALID_PROVIDERS.join(', ')}` });
return;
}

if (typeof key !== 'string' || key.trim().length === 0) {
res.status(400).json({ error: 'key must be a non-empty string' });
return;
}

const stored = addKey(provider, key.trim(), label, priority);
res.status(201).json({
id: stored.id,
provider: stored.provider,
label: stored.label,
maskedKey: maskKey(stored.key),
priority: stored.priority,
source: stored.source,
addedAt: stored.addedAt,
});
} catch (error) {
logger.error('Failed to add key', error, { module: 'key-handler' });
res.status(500).json({ error: 'Failed to add key' });
}
}

export async function handleRemoveKey(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const removed = removeKey(id);
if (!removed) {
res.status(404).json({ error: 'Key not found' });
return;
}
res.json({ status: 'removed', id });
} catch (error) {
logger.error('Failed to remove key', error, { module: 'key-handler' });
res.status(500).json({ error: 'Failed to remove key' });
}
}

export async function handleUpdateKeyPriority(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const { priority } = req.body;

if (typeof priority !== 'number' || priority < 0) {
res.status(400).json({ error: 'priority must be a non-negative number' });
return;
}

const updated = updateKeyPriority(id, priority);
if (!updated) {
res.status(404).json({ error: 'Key not found' });
return;
}
res.json({ status: 'updated', id, priority });
} catch (error) {
logger.error('Failed to update key priority', error, { module: 'key-handler' });
res.status(500).json({ error: 'Failed to update key priority' });
}
}
59 changes: 59 additions & 0 deletions gateway/src/costs/ollama.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
provider: "ollama"
currency: "USD"
unit: "MTok"
models:
# Ollama runs models locally — all costs are zero.
# Users may add custom model entries here if needed.
llama3.3:
input: 0.00
output: 0.00
llama3.2:
input: 0.00
output: 0.00
llama3.1:
input: 0.00
output: 0.00
llama3:
input: 0.00
output: 0.00
gemma3:
input: 0.00
output: 0.00
gemma2:
input: 0.00
output: 0.00
qwen3:
input: 0.00
output: 0.00
qwen2.5-coder:
input: 0.00
output: 0.00
deepseek-r1:
input: 0.00
output: 0.00
deepseek-coder-v2:
input: 0.00
output: 0.00
phi4:
input: 0.00
output: 0.00
phi3:
input: 0.00
output: 0.00
mistral:
input: 0.00
output: 0.00
mixtral:
input: 0.00
output: 0.00
codellama:
input: 0.00
output: 0.00
starcoder2:
input: 0.00
output: 0.00
metadata:
last_updated: "2026-02-03"
source: "https://ollama.com"
notes: "Ollama runs models locally. All API costs are zero — hardware costs are borne by the user."
version: "1.0"
95 changes: 95 additions & 0 deletions gateway/src/domain/providers/ollama-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { BaseProvider } from './base-provider.js';
import { CanonicalRequest, CanonicalResponse } from 'shared/types/index.js';
import { getConfig } from '../../infrastructure/config/app-config.js';

interface OllamaRequest {
model: string;
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string; }>;
max_tokens?: number;
temperature?: number;
stream?: boolean;
stop?: string | string[];
}

interface OllamaResponse {
id: string;
object: string;
created: number;
model: string;
choices: Array<{
index: number;
message: { role: string; content: string; };
finish_reason: string;
}>;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}

export class OllamaProvider extends BaseProvider {
readonly name = 'ollama';

protected get baseUrl(): string {
return getConfig().providers.ollama.baseUrl;
}

protected get apiKey(): string | undefined {
return getConfig().providers.ollama.apiKey || 'ollama';
}

isConfigured(): boolean {
return getConfig().providers.ollama.enabled;
}

protected transformRequest(request: CanonicalRequest): OllamaRequest {
const messages = request.messages.map(msg => ({
role: msg.role,
content: msg.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('')
}));

return {
model: request.model,
messages,
max_tokens: request.maxTokens,
temperature: request.temperature,
stream: request.stream || false,
stop: request.stopSequences
};
}

protected transformResponse(response: OllamaResponse): CanonicalResponse {
const choice = response.choices[0];

return {
id: response.id,
model: response.model,
created: response.created,
message: {
role: 'assistant',
content: [{
type: 'text',
text: choice.message.content
}]
},
finishReason: this.mapFinishReason(choice.finish_reason),
usage: {
inputTokens: response.usage?.prompt_tokens ?? 0,
outputTokens: response.usage?.completion_tokens ?? 0,
totalTokens: response.usage?.total_tokens ?? 0
}
};
}

private mapFinishReason(reason: string): 'stop' | 'length' | 'tool_calls' | 'error' {
switch (reason) {
case 'stop': return 'stop';
case 'length': return 'length';
default: return 'stop';
}
}
}
83 changes: 83 additions & 0 deletions gateway/src/infrastructure/auth/key-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { getKeysForProvider, seedFromEnv, StoredKey } from './key-store.js';
import { logger } from '../utils/logger.js';

const ENV_MAP: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
xai: 'XAI_API_KEY',
zai: 'ZAI_API_KEY',
google: 'GOOGLE_API_KEY',
ollama: 'OLLAMA_API_KEY',
};

const exhaustedKeys = new Map<string, number>();
const EXHAUSTED_COOLDOWN_MS = 300000;

let seeded = false;

function seedAllFromEnv(): void {
if (seeded) return;
seeded = true;
for (const [provider, envVar] of Object.entries(ENV_MAP)) {
seedFromEnv(provider, envVar);
}
}

export function markKeyExhausted(provider: string, keyId: string): void {
exhaustedKeys.set(`${provider}:${keyId}`, Date.now());
logger.info('Key marked exhausted', { provider, keyId, cooldownMs: EXHAUSTED_COOLDOWN_MS, module: 'key-manager' });
}

function isKeyExhausted(provider: string, keyId: string): boolean {
const ts = exhaustedKeys.get(`${provider}:${keyId}`);
if (!ts) return false;
if (Date.now() - ts > EXHAUSTED_COOLDOWN_MS) {
exhaustedKeys.delete(`${provider}:${keyId}`);
return false;
}
return true;
}

export async function resolveKeyForProvider(provider: string): Promise<string | undefined> {
seedAllFromEnv();

if (provider === 'openai' || provider === 'anthropic') {
try {
const oauthPath = new URL('./oauth-service.js', import.meta.url).href;
const mod: any = await import(/* @vite-ignore */ oauthPath).catch(() => null);
if (mod?.getValidAccessToken) {
const oauthToken = await mod.getValidAccessToken(provider);
if (oauthToken) {
logger.debug('Using OAuth token', { provider, module: 'key-manager' });
return oauthToken;
}
}
} catch {}
}

const keys = getKeysForProvider(provider);
for (const key of keys) {
if (!isKeyExhausted(provider, key.id)) {
logger.debug('Using key', { provider, label: key.label, priority: key.priority, module: 'key-manager' });
return key.key;
}
}

if (keys.length > 0) {
logger.warn('All keys exhausted, using first key as fallback', { provider, module: 'key-manager' });
return keys[0].key;
}

return undefined;
}

export function getKeyCountForProvider(provider: string): number {
seedAllFromEnv();
return getKeysForProvider(provider).length;
}

export function hasAnyKeyForProvider(provider: string): boolean {
seedAllFromEnv();
return getKeysForProvider(provider).length > 0;
}
Loading