From e996a541e97d5f2851cb63f8f8dae98c98b29d68 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Mon, 25 May 2026 22:27:06 +0530 Subject: [PATCH 1/6] feat: implement dashboard-managed LLM provider system --- .dev.vars.example | 4 +- .env.test.example | 1 + CONTRIBUTING.md | 3 +- README.md | 4 +- db/migrations/002_llm.sql | 144 +++ db/migrations/002_resumable_queue_jobs.sql | 30 - scripts/migrate.mjs | 14 +- .../features/models/model-chain.tsx | 88 +- src/client/components/ui/select.tsx | 9 +- src/client/lib/api.ts | 38 +- src/client/pages/repos.tsx | 32 +- src/client/pages/settings.tsx | 1020 +++++++++++++++-- src/server/core/llm-crypto.ts | 50 + src/server/core/review.ts | 6 +- src/server/db/model-configs.ts | 416 ++++++- src/server/env.ts | 2 +- src/server/models/anthropic.ts | 61 + src/server/models/catalog.ts | 208 ++++ src/server/models/cloudflare.ts | 12 +- src/server/models/google.ts | 56 +- src/server/models/openai.ts | 71 ++ src/server/models/types.ts | 27 +- src/server/routes/api/models.ts | 323 +++++- src/server/services/model.ts | 145 ++- src/server/worker-env.d.ts | 6 +- src/shared/api.ts | 2 + src/shared/schema.ts | 19 +- test/api.spec.ts | 152 ++- test/helpers.ts | 17 +- test/model-service.spec.ts | 31 +- worker-configuration.d.ts | 2 +- wrangler.jsonc | 2 +- 32 files changed, 2697 insertions(+), 298 deletions(-) create mode 100644 db/migrations/002_llm.sql delete mode 100644 db/migrations/002_resumable_queue_jobs.sql create mode 100644 src/server/core/llm-crypto.ts create mode 100644 src/server/models/anthropic.ts create mode 100644 src/server/models/catalog.ts create mode 100644 src/server/models/openai.ts diff --git a/.dev.vars.example b/.dev.vars.example index 1e05d90..c5819bc 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -5,8 +5,8 @@ # --- Integration tests --- TEST_DATABASE_URL="postgresql://user:password@localhost:5432/codra" -# --- AI provider --- -GEMINI_API_KEY="REPLACE_WITH_YOUR_GEMINI_API_KEY" +# --- LLM provider config encryption --- +LLM_CONFIG_ENCRYPTION_KEY="REPLACE_WITH_A_LONG_RANDOM_ENCRYPTION_KEY" # --- GitHub App and OAuth --- GITHUB_APP_WEBHOOK_SECRET="REPLACE_WITH_YOUR_WEBHOOK_SECRET" diff --git a/.env.test.example b/.env.test.example index 31e1b2e..0cf4c8d 100644 --- a/.env.test.example +++ b/.env.test.example @@ -12,6 +12,7 @@ DASHBOARD_ALLOWED_USERS="devarshishimpi" APP_URL="https://codra.test" BOT_USERNAME="codra-test-app" +LLM_CONFIG_ENCRYPTION_KEY="fake-local-llm-config-encryption-key" # Required. Must point at a disposable Postgres database because tests reset and # write data while running. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a0d1c0..c4ba31f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,8 @@ cp .dev.vars.example .dev.vars You will need to set up: - A GitHub App (for webhooks/checks). - A GitHub OAuth App (for dashboard authentication). -- A Gemini API Key. +- `LLM_CONFIG_ENCRYPTION_KEY` for encrypting dashboard-managed provider API keys. +- LLM providers and model credentials from the Settings dashboard. - A Hyperdrive local connection string for `wrangler dev`. - A direct `DATABASE_URL` for migrations. diff --git a/README.md b/README.md index eb897de..411c3bb 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts - Dead letter queue inspection, replay, and purge workflows - GitHub OAuth dashboard authentication - External PostgreSQL storage through Cloudflare Hyperdrive -- Google Gemini and Cloudflare Workers AI model providers +- Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, and Cloudflare models - Repository settings for labels, skipped globs, custom rules, and model routing ## How It Works @@ -65,7 +65,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts - **Dashboard**: React, Vite, Tailwind CSS, Radix UI, Recharts - **Data**: PostgreSQL, Cloudflare Hyperdrive, Cloudflare KV - **Queues**: Cloudflare Queues with DLQ workflows -- **Models**: Google Gemini and Cloudflare Workers AI +- **Models**: OpenAI, OpenRouter, Anthropic, Google, and Cloudflare providers - **GitHub**: GitHub App webhooks, checks, reviews, and OAuth - **Quality**: TypeScript, Zod, Vitest, Playwright browser tests diff --git a/db/migrations/002_llm.sql b/db/migrations/002_llm.sql new file mode 100644 index 0000000..9921c5c --- /dev/null +++ b/db/migrations/002_llm.sql @@ -0,0 +1,144 @@ +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS check_run_completed_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_owner TEXT; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS recovery_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_queue_message_at TIMESTAMPTZ; +ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS transient_error_count INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS jobs_lease_expiry_idx + ON jobs (lease_expires_at) + WHERE status = 'running' AND lease_expires_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS jobs_terminal_check_idx + ON jobs (status, check_run_completed_at) + WHERE check_run_id IS NOT NULL AND check_run_completed_at IS NULL; + +CREATE INDEX IF NOT EXISTS jobs_unleased_running_idx + ON jobs (last_queue_message_at, heartbeat_at) + WHERE status = 'running' AND lease_expires_at IS NULL; + +DELETE FROM file_reviews fr +USING ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY job_id, file_path ORDER BY created_at ASC, id ASC) AS row_number + FROM file_reviews +) ranked +WHERE fr.id = ranked.id + AND ranked.row_number > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS file_reviews_job_file_path_key + ON file_reviews (job_id, file_path); + +CREATE TABLE IF NOT EXISTS llm_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + api_format TEXT NOT NULL CHECK (api_format IN ('openai', 'anthropic', 'gemini', 'cloudflare-workers-ai')), + base_url TEXT, + encrypted_api_key TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +UPDATE llm_providers +SET name = 'Cloudflare', updated_at = now() +WHERE name = 'Cloudflare Workers AI'; + +UPDATE llm_providers +SET name = 'Google', updated_at = now() +WHERE name = 'Google Gemini'; + +INSERT INTO llm_providers (name, api_format, base_url, enabled) +VALUES + ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE), + ('Google', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', FALSE), + ('OpenAI', 'openai', 'https://api.openai.com/v1', FALSE), + ('Anthropic', 'anthropic', 'https://api.anthropic.com/v1', FALSE), + ('OpenRouter', 'openai', 'https://openrouter.ai/api/v1', FALSE) +ON CONFLICT (name) DO UPDATE SET + api_format = EXCLUDED.api_format, + base_url = EXCLUDED.base_url, + updated_at = now(); + +ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS provider_id UUID; +ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS model_name TEXT; + +UPDATE model_configs mc +SET + provider_id = provider_record.id, + model_name = COALESCE(mc.model_name, mc.model_id) +FROM llm_providers provider_record +WHERE mc.provider_id IS NULL + AND ( + (mc.provider = 'cloudflare' AND provider_record.name = 'Cloudflare') + OR (mc.provider = 'gemini' AND provider_record.name = 'Google') + OR (mc.provider = 'google' AND provider_record.name = 'Google') + OR (mc.provider = 'openai' AND provider_record.name = 'OpenAI') + OR (mc.provider = 'anthropic' AND provider_record.name = 'Anthropic') + ); + +UPDATE model_configs +SET model_name = model_id +WHERE model_name IS NULL; + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) +SELECT '@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare', p.id, '@cf/moonshotai/kimi-k2.6', now() +FROM llm_providers p +WHERE p.name = 'Cloudflare' +ON CONFLICT (model_id) DO UPDATE SET + rpm = EXCLUDED.rpm, + tpm = EXCLUDED.tpm, + rpd = EXCLUDED.rpd, + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) +SELECT '@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now() +FROM llm_providers p +WHERE p.name = 'Cloudflare' +ON CONFLICT (model_id) DO UPDATE SET + rpm = EXCLUDED.rpm, + tpm = EXCLUDED.tpm, + rpd = EXCLUDED.rpd, + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) +SELECT 'gemma-4-31b-it', 15, 1000000, 1500, 'gemini', p.id, 'gemma-4-31b-it', now() +FROM llm_providers p +WHERE p.name = 'Google' +ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) +SELECT 'gemma-4-26b-a4b-it', 30, 1000000, 1500, 'gemini', p.id, 'gemma-4-26b-a4b-it', now() +FROM llm_providers p +WHERE p.name = 'Google' +ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +ALTER TABLE model_configs ALTER COLUMN provider_id SET NOT NULL; +ALTER TABLE model_configs ALTER COLUMN model_name SET NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'model_configs_provider_id_fkey' + ) THEN + ALTER TABLE model_configs + ADD CONSTRAINT model_configs_provider_id_fkey + FOREIGN KEY (provider_id) REFERENCES llm_providers(id); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS model_configs_provider_id_idx ON model_configs (provider_id); diff --git a/db/migrations/002_resumable_queue_jobs.sql b/db/migrations/002_resumable_queue_jobs.sql deleted file mode 100644 index b521486..0000000 --- a/db/migrations/002_resumable_queue_jobs.sql +++ /dev/null @@ -1,30 +0,0 @@ -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS check_run_completed_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_owner TEXT; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS recovery_count INTEGER NOT NULL DEFAULT 0; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_queue_message_at TIMESTAMPTZ; -ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS transient_error_count INTEGER NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS jobs_lease_expiry_idx - ON jobs (lease_expires_at) - WHERE status = 'running' AND lease_expires_at IS NOT NULL; - -CREATE INDEX IF NOT EXISTS jobs_terminal_check_idx - ON jobs (status, check_run_completed_at) - WHERE check_run_id IS NOT NULL AND check_run_completed_at IS NULL; - -CREATE INDEX IF NOT EXISTS jobs_unleased_running_idx - ON jobs (last_queue_message_at, heartbeat_at) - WHERE status = 'running' AND lease_expires_at IS NULL; - -DELETE FROM file_reviews fr -USING ( - SELECT id, ROW_NUMBER() OVER (PARTITION BY job_id, file_path ORDER BY created_at ASC, id ASC) AS row_number - FROM file_reviews -) ranked -WHERE fr.id = ranked.id - AND ranked.row_number > 1; - -CREATE UNIQUE INDEX IF NOT EXISTS file_reviews_job_file_path_key - ON file_reviews (job_id, file_path); diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 9063499..b3e7214 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -231,8 +231,18 @@ async function ensureModelCatalog() { await query( ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider) - VALUES ($1, 10, 131072, 300, 'cloudflare') + INSERT INTO llm_providers (name, api_format, base_url, enabled) + VALUES ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE) + ON CONFLICT (name) DO NOTHING + `, + ); + + await query( + ` + INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name) + SELECT $1, 10, 131072, 300, 'cloudflare', id, $1 + FROM llm_providers + WHERE name = 'Cloudflare' ON CONFLICT (model_id) DO NOTHING `, [kimiK26Model], diff --git a/src/client/components/features/models/model-chain.tsx b/src/client/components/features/models/model-chain.tsx index b31dbce..cb27b60 100644 --- a/src/client/components/features/models/model-chain.tsx +++ b/src/client/components/features/models/model-chain.tsx @@ -4,17 +4,16 @@ import { Select } from '@client/components/ui/select'; import { Button } from '@client/components/ui/button'; import { Trash2, ListPlus } from 'lucide-react'; -export const PROVIDERS = [ - { value: 'cloudflare', label: 'Cloudflare' }, - { value: 'google', label: 'Google' }, -]; +export type ProviderOption = { + value: string; + label: string; +}; -export const MODELS = [ - { value: 'gemma-4-31b-it', label: 'Gemma 4 (31b)', provider: 'google' }, - { value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 (26b)', provider: 'google' }, - { value: '@cf/moonshotai/kimi-k2.6', label: 'Kimi K2.6', provider: 'cloudflare' }, - { value: '@cf/zai-org/glm-4.7-flash', label: 'GLM 4.7 Flash', provider: 'cloudflare' }, -]; +export type ModelOption = { + value: string; + label: string; + providerId: string; +}; export type ModelDensity = 'compact' | 'comfortable'; @@ -30,19 +29,19 @@ export type ModelRouteConfig = { size_overrides: ModelRouteTier[]; }; -export function getProviderLabel(provider: string) { - return PROVIDERS.find(p => p.value === provider)?.label ?? provider; +export function getProviderLabel(provider: string, providers: ProviderOption[] = []) { + return providers.find(p => p.value === provider)?.label ?? provider; } -export function getModelLabel(model: string) { - return MODELS.find(m => m.value === model)?.label ?? model; +export function getModelLabel(model: string, models: ModelOption[] = []) { + return models.find(m => m.value === model)?.label ?? model; } -export function describeModelRoute(config: ModelRouteConfig) { +export function describeModelRoute(config: ModelRouteConfig, models: ModelOption[] = []) { const fallbacks = config.fallbacks?.length ?? 0; const tiers = config.size_overrides?.length ?? 0; return [ - getModelLabel(config.main), + getModelLabel(config.main, models), fallbacks > 0 ? `${fallbacks} fallback${fallbacks === 1 ? '' : 's'}` : 'no fallbacks', tiers > 0 ? `${tiers} tier${tiers === 1 ? '' : 's'}` : 'baseline only', ].join(' · '); @@ -51,6 +50,8 @@ export function describeModelRoute(config: ModelRouteConfig) { interface ModelSelectorProps { value: string; onValueChange: (value: string) => void; + models: ModelOption[]; + providers: ProviderOption[]; hideLabels?: boolean; density?: ModelDensity; className?: string; @@ -59,25 +60,35 @@ interface ModelSelectorProps { export function ModelSelector({ value, onValueChange, + models, + providers, hideLabels, density = 'comfortable', className, }: ModelSelectorProps) { - const currentModel = MODELS.find(m => m.value === value) || MODELS[0]; - const [provider, setProvider] = useState(currentModel.provider); + const currentModel = models.find(m => m.value === value) || models[0]; + const [provider, setProvider] = useState(currentModel?.providerId ?? providers[0]?.value ?? ''); useEffect(() => { - const model = MODELS.find(m => m.value === value); - if (model && model.provider !== provider) { - setProvider(model.provider); + const model = models.find(m => m.value === value); + if (model && model.providerId !== provider) { + setProvider(model.providerId); } - }, [provider, value]); + }, [models, provider, value]); const filteredModels = useMemo( - () => MODELS.filter(m => m.provider === provider).map(m => ({ value: m.value, label: m.label })), - [provider], + () => models.filter(m => m.providerId === provider).map(m => ({ value: m.value, label: m.label })), + [models, provider], ); + if (models.length === 0 || providers.length === 0) { + return ( +
+ No configured models +
+ ); + } + return (
{ setProvider(nextProvider); - const first = MODELS.find(m => m.provider === nextProvider); + const first = models.find(m => m.providerId === nextProvider); if (first) onValueChange(first.value); }} - options={PROVIDERS} + options={providers} triggerClassName={cn(density === 'compact' && 'h-8 text-xs')} /> { + const preset = PROVIDER_PRESETS.find(item => item.value === value) ?? PROVIDER_PRESETS[0]; + setNewProvider(current => ({ + ...current, + preset: preset.value, + name: preset.name, + apiFormat: preset.apiFormat, + baseUrl: preset.baseUrl, + })); + }} + options={PROVIDER_PRESETS.map(preset => ({ value: preset.value, label: preset.label }))} + /> + + + + +
+ {selectedProviderNameExists && ( +

+ {newProvider.name.trim()} already exists. +

+ )} + + )} + +
+ Provider + Type + Models + Credential + Actions +
+ +
+ {providers.map(provider => { + const nativeCloudflare = provider.apiFormat === 'cloudflare-workers-ai'; + const customProvider = isCustomProvider(provider); + const ready = providerIsReady(provider); + const savedProvider = savedProviders.find(saved => saved.id === provider.id); + const dirty = providerDraftDirty(provider, savedProvider); + const modelCount = providerModelCounts.get(provider.id) ?? 0; + const expanded = expandedProviderId === provider.id; + return ( +
+
+
+ + {ready ? : } + +
+

{provider.name}

+
+ + {providerStatusLabel(provider)} + + {formatLabel(provider.apiFormat)} + {modelCount} {modelCount === 1 ? 'model' : 'models'} +
+
+
+ +

{formatLabel(provider.apiFormat)}

+

{modelCount}

+

+ {nativeCloudflare ? 'Worker AI binding' : provider.hasApiKey ? 'Saved key hidden' : 'No API key'} +

+ +
+ + + + {customProvider && ( + + )} +
+
+ + {expanded && ( +
+ {customProvider && ( +
+ + updateProviderDraft(provider.id, { baseUrl: e.target.value || null })} + /> + +
+ )} + + {nativeCloudflare ? ( +
+ Native provider. Calls use the Worker AI binding configured in Wrangler. +
+ ) : ( + + )} +
+ )} +
+ ); + })} +
+ + + )} + +
@@ -234,11 +907,13 @@ export function SettingsPage() {
) : ( -
+
@@ -253,98 +928,237 @@ export function SettingsPage() {
-

Model usage quotas

+

Models and usage limits

- Provider rate limits and token capacity per model. + Codra model IDs, provider model names, and rate metadata.

{loading ? (
{[1, 2, 3].map(i => ( -
+
))}
) : ( -
- {configs.map((cfg, i) => { - const saved = savedConfigs.find(item => item.modelId === cfg.modelId); - const dirty = !quotaEqual(cfg, saved); - return ( -
+
+
+ + Add custom model +
+
+ setNewModel(current => ({ ...current, modelId: e.target.value }))} + /> + setNewModel(current => ({ ...current, modelName: e.target.value }))} + /> + setNewModel(current => ({ ...current, [field]: Number(e.target.value) || 1 }))} + /> + ))} + +
+
+ +
+
+ + updateQuota(cfg.modelId, field, Number(e.target.value) || 0)} - className="mt-2 h-8 min-w-0 w-full bg-transparent text-left text-lg font-semibold text-foreground outline-none" - /> - - ))} + {expanded && ( +
+
+ updateModel(cfg.modelId, { modelName: e.target.value })} + className="mt-1.5" + /> + + {(['rpm', 'rpd', 'tpm'] as const).map(field => ( + + ))} +
+
+ )} +
+ ); + })} + + {filteredConfigs.length === 0 && ( +
+ No models match the current filters.
- - ); - })} + )} +
+
)}
diff --git a/src/server/core/llm-crypto.ts b/src/server/core/llm-crypto.ts new file mode 100644 index 0000000..805980e --- /dev/null +++ b/src/server/core/llm-crypto.ts @@ -0,0 +1,50 @@ +import type { AppBindings } from '@server/env'; + +const KEY_VERSION = 'v1'; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function toBase64(bytes: Uint8Array) { + return Buffer.from(bytes).toString('base64'); +} + +function fromBase64(value: string) { + return new Uint8Array(Buffer.from(value, 'base64')); +} + +async function importEncryptionKey(secret: string) { + if (!secret || secret.trim().length < 16) { + throw new Error('LLM_CONFIG_ENCRYPTION_KEY must be at least 16 characters long.'); + } + + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(secret)); + return crypto.subtle.importKey('raw', digest, 'AES-GCM', false, ['encrypt', 'decrypt']); +} + +export async function encryptLlmApiKey(env: Pick, apiKey: string) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await importEncryptionKey(env.LLM_CONFIG_ENCRYPTION_KEY); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoder.encode(apiKey), + ); + + return `${KEY_VERSION}:${toBase64(iv)}:${toBase64(new Uint8Array(ciphertext))}`; +} + +export async function decryptLlmApiKey(env: Pick, encrypted: string) { + const [version, ivBase64, ciphertextBase64] = encrypted.split(':'); + if (version !== KEY_VERSION || !ivBase64 || !ciphertextBase64) { + throw new Error('Unsupported encrypted LLM API key format.'); + } + + const key = await importEncryptionKey(env.LLM_CONFIG_ENCRYPTION_KEY); + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: fromBase64(ivBase64) }, + key, + fromBase64(ciphertextBase64), + ); + + return decoder.decode(plaintext); +} diff --git a/src/server/core/review.ts b/src/server/core/review.ts index c4d4923..36f2867 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -562,7 +562,7 @@ async function reviewAndPersistFile( const failureCount = await recordRetryableFileReviewFailure(env, job.id, { filePath: file.path, modelUsed: modelId, - modelProvider: modelId.startsWith('@cf/') ? 'cloudflare' : 'google', + modelProvider: 'configured', diffLineCount: file.lineCount, diffInput: '', durationMs: Date.now() - startedAt, @@ -575,7 +575,7 @@ async function reviewAndPersistFile( filePath: file.path, fileStatus: 'failed', modelUsed: modelId, - modelProvider: modelId.startsWith('@cf/') ? 'cloudflare' : 'google', + modelProvider: 'configured', diffLineCount: file.lineCount, diffInput: '', rawAiOutput: null, @@ -621,7 +621,7 @@ async function reviewAndPersistFile( filePath: file.path, fileStatus: 'failed', modelUsed: modelId, - modelProvider: modelId.startsWith('@cf/') ? 'cloudflare' : 'google', + modelProvider: 'configured', diffLineCount: file.lineCount, diffInput: '', rawAiOutput: null, diff --git a/src/server/db/model-configs.ts b/src/server/db/model-configs.ts index 7755cf1..f15979a 100644 --- a/src/server/db/model-configs.ts +++ b/src/server/db/model-configs.ts @@ -1,31 +1,221 @@ import type { AppBindings } from '@server/env'; import { queryRows } from './client'; -import { KIMI_K2_5_MODEL, modelConfigSchema, type ModelConfig } from '@shared/schema'; +import { + KIMI_K2_5_MODEL, + llmProviderSchema, + modelConfigSchema, + type LlmApiFormat, + type LlmProvider, + type ModelConfig, +} from '@shared/schema'; + +type ProviderRow = { + id: string; + name: string; + api_format: LlmApiFormat; + base_url: string | null; + encrypted_api_key: string | null; + enabled: boolean; + created_at: string; + updated_at: string; +}; type ModelConfigRow = { model_id: string; + provider_id: string; + provider_name: string; + api_format: LlmApiFormat; + model_name: string; rpm: number; tpm: number; rpd: number; - provider: string; updated_at: string; }; +export type LlmProviderSecret = LlmProvider & { + encryptedApiKey: string | null; +}; + +export type ResolvedModelConfig = ModelConfig & { + providerEnabled: boolean; + baseUrl: string | null; + encryptedApiKey: string | null; +}; + +function mapProvider(row: ProviderRow): LlmProvider { + return llmProviderSchema.parse({ + id: row.id, + name: row.name, + apiFormat: row.api_format, + baseUrl: row.base_url, + enabled: row.enabled, + hasApiKey: Boolean(row.encrypted_api_key), + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +} + +function mapProviderSecret(row: ProviderRow): LlmProviderSecret { + return { + ...mapProvider(row), + encryptedApiKey: row.encrypted_api_key, + }; +} + function mapModelConfig(row: ModelConfigRow): ModelConfig { return modelConfigSchema.parse({ modelId: row.model_id, + providerId: row.provider_id, + providerName: row.provider_name, + apiFormat: row.api_format, + modelName: row.model_name, rpm: row.rpm, tpm: row.tpm, rpd: row.rpd, - provider: row.provider, updatedAt: row.updated_at, }); } +const MODEL_SELECT = ` + SELECT + mc.model_id, + mc.provider_id, + p.name AS provider_name, + p.api_format, + mc.model_name, + mc.rpm, + mc.tpm, + mc.rpd, + mc.updated_at + FROM model_configs mc + JOIN llm_providers p ON p.id = mc.provider_id +`; + +export async function listLlmProviders(env: Pick): Promise { + const rows = await queryRows( + env, + `SELECT id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + FROM llm_providers + ORDER BY name ASC`, + ); + return rows.map(mapProvider); +} + +export async function listLlmProviderSecrets(env: Pick): Promise { + const rows = await queryRows( + env, + `SELECT id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + FROM llm_providers + ORDER BY name ASC`, + ); + return rows.map(mapProviderSecret); +} + +export async function getLlmProvider(env: Pick, id: string): Promise { + const [row] = await queryRows( + env, + `SELECT id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + FROM llm_providers + WHERE id = $1`, + [id], + ); + return row ? mapProviderSecret(row) : null; +} + +export async function createLlmProvider( + env: Pick, + input: { + name: string; + apiFormat: LlmApiFormat; + baseUrl: string | null; + encryptedApiKey: string | null; + enabled: boolean; + }, +) { + const [row] = await queryRows( + env, + ` + INSERT INTO llm_providers (name, api_format, base_url, encrypted_api_key, enabled, updated_at) + VALUES ($1, $2, $3, $4, $5, now()) + RETURNING id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + `, + [input.name, input.apiFormat, input.baseUrl, input.encryptedApiKey, input.enabled], + ); + return mapProvider(row); +} + +export async function findLlmProviderByName(env: Pick, name: string): Promise { + const [row] = await queryRows( + env, + `SELECT id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + FROM llm_providers + WHERE lower(name) = lower($1)`, + [name], + ); + return row ? mapProvider(row) : null; +} + +export async function updateLlmProvider( + env: Pick, + id: string, + input: { + name: string; + apiFormat: LlmApiFormat; + baseUrl: string | null; + encryptedApiKey?: string | null; + enabled: boolean; + }, +) { + const params: unknown[] = [id, input.name, input.apiFormat, input.baseUrl, input.enabled]; + let apiKeySql = ''; + if (input.encryptedApiKey !== undefined) { + params.push(input.encryptedApiKey); + apiKeySql = `, encrypted_api_key = $${params.length}`; + } + + const [row] = await queryRows( + env, + ` + UPDATE llm_providers + SET + name = $2, + api_format = $3, + base_url = $4, + enabled = $5, + updated_at = now() + ${apiKeySql} + WHERE id = $1 + RETURNING id, name, api_format, base_url, encrypted_api_key, enabled, created_at, updated_at + `, + params, + ); + return row ? mapProvider(row) : null; +} + +export async function deleteLlmProvider(env: Pick, id: string) { + const [{ count }] = await queryRows<{ count: string }>( + env, + `SELECT COUNT(*)::text AS count FROM model_configs WHERE provider_id = $1`, + [id], + ); + if (Number(count) > 0) { + return { deleted: false, reason: 'Provider is still used by one or more models.' }; + } + + const rows = await queryRows<{ id: string }>( + env, + `DELETE FROM llm_providers WHERE id = $1 RETURNING id`, + [id], + ); + return { deleted: rows.length > 0, reason: null }; +} + export async function listModelConfigs(env: Pick): Promise { const rows = await queryRows( env, - `SELECT model_id, rpm, tpm, rpd, provider, updated_at FROM model_configs WHERE model_id <> $1 ORDER BY model_id ASC`, + `${MODEL_SELECT} + WHERE mc.model_id <> $1 + ORDER BY mc.model_id ASC`, [KIMI_K2_5_MODEL], ); return rows.map(mapModelConfig); @@ -34,29 +224,219 @@ export async function listModelConfigs(env: Pick): Pr export async function getModelConfig(env: Pick, modelId: string): Promise { const [row] = await queryRows( env, - `SELECT model_id, rpm, tpm, rpd, provider, updated_at FROM model_configs WHERE model_id = $1`, - [modelId] + `${MODEL_SELECT} + WHERE mc.model_id = $1`, + [modelId], ); return row ? mapModelConfig(row) : null; } +export async function getResolvedModelConfig( + env: Pick, + modelId: string, +): Promise { + const [row] = await queryRows( + env, + ` + SELECT + mc.model_id, + mc.provider_id, + p.name AS provider_name, + p.api_format, + mc.model_name, + mc.rpm, + mc.tpm, + mc.rpd, + mc.updated_at, + p.enabled AS provider_enabled, + p.base_url, + p.encrypted_api_key + FROM model_configs mc + JOIN llm_providers p ON p.id = mc.provider_id + WHERE mc.model_id = $1 + `, + [modelId], + ); + + if (!row) return null; + return { + ...mapModelConfig(row), + providerEnabled: row.provider_enabled, + baseUrl: row.base_url, + encryptedApiKey: row.encrypted_api_key, + }; +} + export async function updateModelConfig( env: Pick, - config: Omit + config: Omit, ) { - await queryRows( + const [row] = await queryRows( env, ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, updated_at) - VALUES ($1, $2, $3, $4, $5, now()) - ON CONFLICT (model_id) - DO UPDATE SET - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, - provider = EXCLUDED.provider, - updated_at = now() + WITH upserted AS ( + INSERT INTO model_configs (model_id, provider_id, model_name, rpm, tpm, rpd, provider, updated_at) + SELECT $1, p.id, $3, $4, $5, $6, p.api_format, now() + FROM llm_providers p + WHERE p.id = $2 + ON CONFLICT (model_id) + DO UPDATE SET + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + rpm = EXCLUDED.rpm, + tpm = EXCLUDED.tpm, + rpd = EXCLUDED.rpd, + provider = EXCLUDED.provider, + updated_at = now() + RETURNING model_id, provider_id, model_name, rpm, tpm, rpd, updated_at + ) + SELECT + u.model_id, + u.provider_id, + p.name AS provider_name, + p.api_format, + u.model_name, + u.rpm, + u.tpm, + u.rpd, + u.updated_at + FROM upserted u + JOIN llm_providers p ON p.id = u.provider_id + `, + [config.modelId, config.providerId, config.modelName, config.rpm, config.tpm, config.rpd], + ); + return row ? mapModelConfig(row) : null; +} + +function slugify(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'provider'; +} + +export async function upsertDiscoveredModelConfigs( + env: Pick, + input: { + providerId: string; + providerName: string; + apiFormat: LlmApiFormat; + modelNames: string[]; + }, +) { + const uniqueModelNames = Array.from(new Set(input.modelNames.map(name => name.trim()).filter(Boolean))); + if (uniqueModelNames.length === 0) return []; + + const [existingForProvider, existingModelIds] = await Promise.all([ + queryRows<{ model_id: string; model_name: string }>( + env, + `SELECT model_id, model_name FROM model_configs WHERE provider_id = $1`, + [input.providerId], + ), + queryRows<{ model_id: string }>( + env, + `SELECT model_id FROM model_configs`, + ), + ]); + + const existingModelNames = new Set(existingForProvider.map(row => row.model_name)); + const usedModelIds = new Set(existingModelIds.map(row => row.model_id)); + const providerSlug = slugify(input.providerName); + const rowsToInsert: Array<{ + model_id: string; + provider_id: string; + model_name: string; + rpm: number; + tpm: number; + rpd: number; + provider: LlmApiFormat; + }> = []; + + for (const modelName of uniqueModelNames) { + if (existingModelNames.has(modelName)) continue; + + const base = `${providerSlug}:${modelName}`; + let candidate = base; + let suffix = 2; + while (usedModelIds.has(candidate)) { + candidate = `${base}-${suffix}`; + suffix++; + } + usedModelIds.add(candidate); + + rowsToInsert.push({ + model_id: candidate, + provider_id: input.providerId, + model_name: modelName, + rpm: 60, + tpm: 1_000_000, + rpd: 1_000, + provider: input.apiFormat, + }); + } + + if (rowsToInsert.length === 0) return []; + + const modelIds = rowsToInsert.map(row => row.model_id); + const providerIds = rowsToInsert.map(row => row.provider_id); + const modelNames = rowsToInsert.map(row => row.model_name); + const rpms = rowsToInsert.map(row => row.rpm); + const tpms = rowsToInsert.map(row => row.tpm); + const rpds = rowsToInsert.map(row => row.rpd); + const providers = rowsToInsert.map(row => row.provider); + + const rows = await queryRows( + env, + ` + WITH incoming AS ( + SELECT * + FROM unnest( + $1::text[], + $2::uuid[], + $3::text[], + $4::integer[], + $5::integer[], + $6::integer[], + $7::text[] + ) AS item(model_id, provider_id, model_name, rpm, tpm, rpd, provider) + ), + inserted AS ( + INSERT INTO model_configs (model_id, provider_id, model_name, rpm, tpm, rpd, provider, updated_at) + SELECT model_id, provider_id, model_name, rpm, tpm, rpd, provider, now() + FROM incoming + ON CONFLICT (model_id) DO NOTHING + RETURNING model_id, provider_id, model_name, rpm, tpm, rpd, updated_at + ) + SELECT + i.model_id, + i.provider_id, + p.name AS provider_name, + p.api_format, + i.model_name, + i.rpm, + i.tpm, + i.rpd, + i.updated_at + FROM inserted i + JOIN llm_providers p ON p.id = i.provider_id + ORDER BY i.model_id ASC `, - [config.modelId, config.rpm, config.tpm, config.rpd, config.provider] + [modelIds, providerIds, modelNames, rpms, tpms, rpds, providers], + ); + + return rows.map(mapModelConfig); +} + +export async function deleteModelConfig(env: Pick, modelId: string) { + const rows = await queryRows<{ model_id: string }>( + env, + `DELETE FROM model_configs WHERE model_id = $1 RETURNING model_id`, + [modelId], ); + return rows.length > 0; } diff --git a/src/server/env.ts b/src/server/env.ts index 03a765b..34dddf1 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -40,7 +40,7 @@ export interface AppBindings { AUTH_CALLBACK_URL: string; APP_URL: string; DASHBOARD_ALLOWED_USERS: string; - GEMINI_API_KEY: string; + LLM_CONFIG_ENCRYPTION_KEY: string; BOT_USERNAME: string; ENVIRONMENT: string; CF_API_TOKEN: string; diff --git a/src/server/models/anthropic.ts b/src/server/models/anthropic.ts new file mode 100644 index 0000000..364e9c2 --- /dev/null +++ b/src/server/models/anthropic.ts @@ -0,0 +1,61 @@ +import { logger } from '@server/core/logger'; +import { withTimeout } from '@server/core/timeout'; +import { ProviderRequestError, providerErrorMessage, type ModelResponse } from './types'; + +const ANTHROPIC_TIMEOUT_MS = 180_000; +const ANTHROPIC_MAX_OUTPUT_TOKENS = 4096; +const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'; + +export async function reviewWithAnthropic( + config: { apiKey: string; baseUrl?: string | null; providerName: string }, + model: string, + input: { systemPrompt: string; userPrompt: string }, + tracker?: { incrementSubrequests(count?: number): void }, +): Promise { + logger.info(`Calling Anthropic model: ${model}`); + const baseUrl = (config.baseUrl || DEFAULT_ANTHROPIC_BASE_URL).replace(/\/+$/, ''); + + if (tracker) tracker.incrementSubrequests(1); + const response = await withTimeout('Anthropic API', ANTHROPIC_TIMEOUT_MS, (signal) => + fetch(`${baseUrl}/messages`, { + method: 'POST', + signal, + headers: { + 'content-type': 'application/json', + 'x-api-key': config.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + system: `${input.systemPrompt}\n\nReturn only the JSON object. Do not include chain-of-thought, analysis, markdown, code fences, or explanatory prose.`, + messages: [ + { role: 'user', content: `${input.userPrompt}\n\nRespond with the required JSON object only.` }, + ], + max_tokens: ANTHROPIC_MAX_OUTPUT_TOKENS, + temperature: 0, + }), + }), + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new ProviderRequestError(config.providerName, response.status, providerErrorMessage(errorText)); + } + + const data = await response.json() as any; + const rawText = Array.isArray(data.content) + ? data.content.map((part: any) => typeof part?.text === 'string' ? part.text : '').join('').trim() + : ''; + + if (!rawText) { + throw new Error('Anthropic provider returned an empty response.'); + } + + return { + rawText, + inputTokens: data?.usage?.input_tokens ?? 0, + outputTokens: data?.usage?.output_tokens ?? 0, + modelUsed: model, + provider: config.providerName, + }; +} diff --git a/src/server/models/catalog.ts b/src/server/models/catalog.ts new file mode 100644 index 0000000..9dbdfc1 --- /dev/null +++ b/src/server/models/catalog.ts @@ -0,0 +1,208 @@ +import type { LlmApiFormat } from '@shared/schema'; +import { withTimeout } from '@server/core/timeout'; + +const MODEL_LIST_TIMEOUT_MS = 8_000; +const ERROR_BODY_LIMIT = 500; +const CLOUDFLARE_TEXT_GENERATION_MODELS = [ + '@cf/moonshotai/kimi-k2.6', + '@cf/zai-org/glm-4.7-flash', + '@cf/openai/gpt-oss-120b', + '@cf/meta/llama-4-scout-17b-16e-instruct', + '@cf/google/gemma-4-26b-a4b-it', + '@cf/nvidia/nemotron-3-120b-a12b', + '@cf/moonshotai/kimi-k2.5', + '@cf/ibm/granite-4.0-h-micro', + '@cf/aisingapore/gemma-sea-lion-v4-27b-it', + '@cf/openai/gpt-oss-20b', + '@cf/qwen/qwen3-30b-a3b-fp8', + '@cf/google/gemma-3-12b-it', + '@cf/mistral/mistral-small-3.1-24b-instruct', + '@cf/qwen/qwq-32b', + '@cf/qwen/qwen2.5-coder-32b-instruct', + '@cf/meta/llama-guard-3-8b', + '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', + '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + '@cf/meta/llama-3.2-1b-instruct', + '@cf/meta/llama-3.2-3b-instruct', + '@cf/meta/llama-3.2-11b-vision-instruct', + '@cf/meta/llama-3.1-8b-instruct-awq', + '@cf/meta/llama-3.1-8b-instruct-fp8', + '@cf/meta/llama-3.1-8b-instruct', + '@cf/meta-llama/meta-llama-3-8b-instruct', + '@cf/meta/llama-3-8b-instruct-awq', + '@cf/meta/llama-3-8b-instruct', + '@cf/mistral/mistral-7b-instruct-v0.2', + '@cf/google/gemma-7b-it-lora', + '@cf/google/gemma-2b-it-lora', + '@cf/meta-llama/llama-2-7b-chat-hf-lora', + '@cf/google/gemma-7b-it', + '@cf/nexusflow/starling-lm-7b-beta', + '@cf/nousresearch/hermes-2-pro-mistral-7b', + '@cf/mistral/mistral-7b-instruct-v0.2-lora', + '@cf/qwen/qwen1.5-1.8b-chat', + '@cf/microsoft/phi-2', + '@cf/tinyllama/tinyllama-1.1b-chat-v1.0', + '@cf/qwen/qwen1.5-14b-chat-awq', + '@cf/qwen/qwen1.5-7b-chat-awq', + '@cf/qwen/qwen1.5-0.5b-chat', + '@cf/thebloke/discolm-german-7b-v1-awq', + '@cf/tiiuae/falcon-7b-instruct', + '@cf/openchat/openchat-3.5-0106', + '@cf/defog/sqlcoder-7b-2', + '@cf/deepseek-ai/deepseek-math-7b-instruct', + '@cf/thebloke/deepseek-coder-6.7b-instruct-awq', + '@cf/thebloke/deepseek-coder-6.7b-base-awq', + '@cf/thebloke/llamaguard-7b-awq', + '@cf/thebloke/neural-chat-7b-v3-1-awq', + '@cf/thebloke/openhermes-2.5-mistral-7b-awq', + '@cf/thebloke/llama-2-13b-chat-awq', + '@cf/thebloke/mistral-7b-instruct-v0.1-awq', + '@cf/thebloke/zephyr-7b-beta-awq', + '@cf/meta/llama-2-7b-chat-fp16', + '@cf/mistral/mistral-7b-instruct-v0.1', + '@cf/meta/llama-2-7b-chat-int8', + '@cf/meta/llama-3.1-70b-instruct', + '@cf/meta/llama-3.1-8b-instruct-fast', +]; + +function cleanGeminiModelName(name: string) { + return name.startsWith('models/') ? name.slice('models/'.length) : name; +} + +function extractOpenAiModels(data: any) { + return Array.isArray(data?.data) + ? data.data.map((item: any) => item?.id).filter((id: unknown): id is string => typeof id === 'string' && id.length > 0) + : []; +} + +function extractAnthropicModels(data: any) { + return Array.isArray(data?.data) + ? data.data.map((item: any) => item?.id).filter((id: unknown): id is string => typeof id === 'string' && id.length > 0) + : []; +} + +function extractGeminiModels(data: any) { + if (!Array.isArray(data?.models)) return []; + return data.models + .filter((model: any) => Array.isArray(model?.supportedGenerationMethods) + ? model.supportedGenerationMethods.includes('generateContent') + : true) + .map((model: any) => typeof model?.name === 'string' ? cleanGeminiModelName(model.name) : null) + .filter((id: unknown): id is string => typeof id === 'string' && id.length > 0); +} + +export async function listProviderModels(input: { + apiFormat: LlmApiFormat; + baseUrl: string | null; + apiKey?: string; + cloudflareAccountId?: string; + cloudflareApiToken?: string; +}) { + const baseUrl = (input.baseUrl || defaultBaseUrl(input.apiFormat)).replace(/\/+$/, ''); + + if (input.apiFormat === 'openai') { + if (!input.apiKey) throw new Error('OpenAI API key is required to list models.'); + const apiKey = input.apiKey; + const response = await withTimeout('OpenAI model list', MODEL_LIST_TIMEOUT_MS, (signal) => + fetch(`${baseUrl}/models`, { + signal, + headers: { + authorization: `Bearer ${apiKey}`, + }, + }), + ); + if (!response.ok) throw new Error(`OpenAI model list failed with ${response.status}: ${await limitedErrorBody(response)}`); + return extractOpenAiModels(await response.json()); + } + + if (input.apiFormat === 'anthropic') { + if (!input.apiKey) throw new Error('Anthropic API key is required to list models.'); + const apiKey = input.apiKey; + const response = await withTimeout('Anthropic model list', MODEL_LIST_TIMEOUT_MS, (signal) => + fetch(`${baseUrl}/models`, { + signal, + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + }), + ); + if (!response.ok) throw new Error(`Anthropic model list failed with ${response.status}: ${await limitedErrorBody(response)}`); + return extractAnthropicModels(await response.json()); + } + + if (input.apiFormat === 'gemini') { + if (!input.apiKey) throw new Error('Google API key is required to list models.'); + const apiKey = input.apiKey; + const url = `${baseUrl}/models?key=${encodeURIComponent(apiKey)}`; + const response = await withTimeout('Google model list', MODEL_LIST_TIMEOUT_MS, (signal) => + fetch(url, { signal }), + ); + if (!response.ok) throw new Error(`Google model list failed with ${response.status}: ${await limitedErrorBody(response)}`); + return extractGeminiModels(await response.json()); + } + + return listCloudflareModels(input.cloudflareAccountId, input.cloudflareApiToken); +} + +async function listCloudflareModels(accountId?: string, apiToken?: string) { + if (!accountId || !apiToken) return CLOUDFLARE_TEXT_GENERATION_MODELS; + + const url = new URL(`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/models/search`); + url.searchParams.set('task', 'Text Generation'); + url.searchParams.set('per_page', '100'); + + const response = await withTimeout('Cloudflare model list', MODEL_LIST_TIMEOUT_MS, (signal) => + fetch(url.toString(), { + signal, + headers: { + authorization: `Bearer ${apiToken}`, + }, + }), + ); + + if (response.status === 401 || response.status === 403) { + return CLOUDFLARE_TEXT_GENERATION_MODELS; + } + if (!response.ok) throw new Error(`Cloudflare model list failed with ${response.status}: ${await limitedErrorBody(response)}`); + const models = extractCloudflareModels(await response.json()); + return models.length > 0 ? models : CLOUDFLARE_TEXT_GENERATION_MODELS; +} + +function extractCloudflareModels(data: any) { + const items = Array.isArray(data?.result) + ? data.result + : Array.isArray(data?.result?.data) + ? data.result.data + : Array.isArray(data?.data) + ? data.data + : []; + + return Array.from(new Set( + items + .map((item: any) => normalizeCloudflareModelId(item?.id ?? item?.name ?? item?.model ?? item?.model_id)) + .filter((id: unknown): id is string => typeof id === 'string' && id.startsWith('@cf/')), + )); +} + +function normalizeCloudflareModelId(value: unknown) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('@cf/')) return trimmed; + if (trimmed.startsWith('cf/')) return `@${trimmed}`; + return null; +} + +async function limitedErrorBody(response: Response) { + const body = await response.text().catch(() => ''); + if (!body) return response.statusText || 'request failed'; + return body.length > ERROR_BODY_LIMIT ? `${body.slice(0, ERROR_BODY_LIMIT)}...` : body; +} + +function defaultBaseUrl(apiFormat: LlmApiFormat) { + if (apiFormat === 'cloudflare-workers-ai') return ''; + if (apiFormat === 'gemini') return 'https://generativelanguage.googleapis.com/v1beta'; + if (apiFormat === 'anthropic') return 'https://api.anthropic.com/v1'; + return 'https://api.openai.com/v1'; +} diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index 36d4bc2..1e58899 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -1,7 +1,7 @@ import { logger } from '@server/core/logger'; import type { AppBindings } from '@server/env'; import { TimeoutError } from '@server/core/timeout'; -import type { ModelResponse } from './types'; +import { ProviderRequestError, type ModelResponse } from './types'; /** Max wall-clock time allowed for a single Workers-AI call. */ const CLOUDFLARE_TIMEOUT_MS = 180_000; @@ -154,6 +154,7 @@ export async function reviewWithCloudflare( model: string, input: { systemPrompt: string; userPrompt: string }, tracker?: { incrementSubrequests(count?: number): void }, + providerName = 'Cloudflare', ): Promise { const maxRetries = CLOUDFLARE_MAX_RETRIES; let lastError: unknown; @@ -209,11 +210,18 @@ export async function reviewWithCloudflare( inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, modelUsed: model, - provider: 'cloudflare', + provider: providerName, }; } catch (error) { lastError = error; const errorMsg = error instanceof Error ? error.message : String(error); + + if (errorMsg.includes('Binding AI needs to be run remotely')) { + const message = 'Cloudflare Workers AI is not available in local Wrangler. Run with remote bindings or deploy the Worker to test Cloudflare models.'; + logger.warn(message, { model }); + throw new ProviderRequestError(providerName, 400, message); + } + logger.error(`Cloudflare request failed (attempt ${attempt}/${maxRetries})`, { error: errorMsg }); // If we've used up our neuron quota, don't retry - it's a persistent error for this account/day diff --git a/src/server/models/google.ts b/src/server/models/google.ts index ac31761..997fda9 100644 --- a/src/server/models/google.ts +++ b/src/server/models/google.ts @@ -1,27 +1,28 @@ import { logger } from '@server/core/logger'; -import type { AppBindings } from '@server/env'; import { withTimeout } from '@server/core/timeout'; -import type { ModelResponse } from './types'; +import { ProviderRequestError, providerErrorMessage, type ModelResponse } from './types'; /** Max wall-clock time allowed for a single Google AI Studio call. */ -const GOOGLE_TIMEOUT_MS = 180_000; -const GOOGLE_MAX_RETRIES = 1; -const GOOGLE_MAX_OUTPUT_TOKENS = 4096; +const GEMINI_TIMEOUT_MS = 180_000; +const GEMINI_MAX_RETRIES = 1; +const GEMINI_MAX_OUTPUT_TOKENS = 4096; +const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; -function isRetryableGoogleStatus(status: number) { - return status === 408 || status === 503 || status === 524; +function isRetryableGeminiStatus(status: number) { + return status === 408 || status === 500 || status === 502 || status === 503 || status === 504 || status === 524; } export async function reviewWithGoogle( - env: Pick, + config: { apiKey: string; baseUrl?: string | null; providerName?: string }, model: string, input: { systemPrompt: string; userPrompt: string }, tracker?: { incrementSubrequests(count?: number): void }, ): Promise { - logger.info(`Calling Google AI model: ${model}`); + logger.info(`Calling Google model: ${model}`); const startTime = Date.now(); - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${env.GEMINI_API_KEY}`; - const maxRetries = GOOGLE_MAX_RETRIES; + const baseUrl = (config.baseUrl || DEFAULT_GEMINI_BASE_URL).replace(/\/+$/, ''); + const url = `${baseUrl}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(config.apiKey)}`; + const maxRetries = GEMINI_MAX_RETRIES; let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { @@ -29,11 +30,11 @@ export async function reviewWithGoogle( if (tracker) tracker.incrementSubrequests(1); if (attempt > 0) { const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000; - logger.info(`Retrying Google request (attempt ${attempt}/${maxRetries}) in ${Math.round(delay)}ms`); + logger.info(`Retrying Gemini request (attempt ${attempt}/${maxRetries}) in ${Math.round(delay)}ms`); await new Promise(resolve => setTimeout(resolve, delay)); } - const response = await withTimeout('Google API', GOOGLE_TIMEOUT_MS, (signal) => + const response = await withTimeout('Gemini API', GEMINI_TIMEOUT_MS, (signal) => fetch(url, { method: 'POST', signal, @@ -52,7 +53,7 @@ export async function reviewWithGoogle( ], generationConfig: { responseMimeType: 'application/json', - maxOutputTokens: GOOGLE_MAX_OUTPUT_TOKENS, + maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS, }, }), }), @@ -60,29 +61,22 @@ export async function reviewWithGoogle( if (!response.ok) { const errorText = await response.text(); - const isRateLimit = response.status === 429; - const isRetryable = isRetryableGoogleStatus(response.status); + const message = providerErrorMessage(errorText); + const isRetryable = isRetryableGeminiStatus(response.status); const logData = { - error: errorText, + error: message, attempt, willRetry: isRetryable && attempt < maxRetries, }; if (isRetryable && attempt < maxRetries) { - logger.warn(`Google request failed with ${response.status}; retrying`, logData); - } else { - logger.error(`Google request failed with ${response.status}`, logData); - } - - if (isRateLimit) { - throw new Error(`Google request failed with ${response.status}: ${errorText}`); - } - - if (isRetryable && attempt < maxRetries) { - lastError = new Error(`Google request failed with ${response.status}: ${errorText}`); + logger.warn(`Gemini request failed with ${response.status}; retrying`, logData); + lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message); continue; } - throw new Error(`Google request failed with ${response.status}: ${errorText}`); + + logger.error(`Gemini request failed with ${response.status}`, logData); + throw new ProviderRequestError(config.providerName ?? 'Google', response.status, message); } const durationMs = Date.now() - startTime; @@ -98,7 +92,7 @@ export async function reviewWithGoogle( const rawText = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? '').join('')?.trim(); if (!rawText) { - throw new Error('Google returned an empty response.'); + throw new Error('Gemini returned an empty response.'); } return { @@ -106,7 +100,7 @@ export async function reviewWithGoogle( inputTokens: data.usageMetadata?.promptTokenCount ?? 0, outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0, modelUsed: model, - provider: 'google', + provider: config.providerName ?? 'Google', }; } catch (error) { lastError = error; diff --git a/src/server/models/openai.ts b/src/server/models/openai.ts new file mode 100644 index 0000000..1d25446 --- /dev/null +++ b/src/server/models/openai.ts @@ -0,0 +1,71 @@ +import { logger } from '@server/core/logger'; +import { withTimeout } from '@server/core/timeout'; +import { ProviderRequestError, providerErrorMessage, type ModelResponse } from './types'; + +const OPENAI_TIMEOUT_MS = 180_000; +const OPENAI_MAX_OUTPUT_TOKENS = 4096; + +function extractOpenAiText(data: any) { + const messageContent = data?.choices?.[0]?.message?.content; + if (typeof messageContent === 'string') return messageContent.trim(); + if (Array.isArray(messageContent)) { + return messageContent.map((part) => typeof part?.text === 'string' ? part.text : '').join('').trim(); + } + const outputText = data?.output_text; + if (typeof outputText === 'string') return outputText.trim(); + return ''; +} + +export async function reviewWithOpenAI( + config: { apiKey: string | null; baseUrl: string; providerName: string }, + model: string, + input: { systemPrompt: string; userPrompt: string }, + tracker?: { incrementSubrequests(count?: number): void }, +): Promise { + logger.info(`Calling OpenAI-format model: ${model}`); + const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`; + + if (tracker) tracker.incrementSubrequests(1); + const response = await withTimeout('OpenAI API', OPENAI_TIMEOUT_MS, (signal) => + fetch(url, { + method: 'POST', + signal, + headers: { + 'content-type': 'application/json', + ...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}), + }, + body: JSON.stringify({ + model, + messages: [ + { + role: 'system', + content: `${input.systemPrompt}\n\nReturn only the JSON object. Do not include chain-of-thought, analysis, markdown, code fences, or explanatory prose.`, + }, + { role: 'user', content: `${input.userPrompt}\n\nRespond with the required JSON object only.` }, + ], + temperature: 0, + max_tokens: OPENAI_MAX_OUTPUT_TOKENS, + response_format: { type: 'json_object' }, + }), + }), + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new ProviderRequestError(config.providerName, response.status, providerErrorMessage(errorText)); + } + + const data = await response.json() as any; + const rawText = extractOpenAiText(data); + if (!rawText) { + throw new Error('OpenAI provider returned an empty response.'); + } + + return { + rawText, + inputTokens: data?.usage?.prompt_tokens ?? data?.usage?.input_tokens ?? 0, + outputTokens: data?.usage?.completion_tokens ?? data?.usage?.output_tokens ?? 0, + modelUsed: model, + provider: config.providerName, + }; +} diff --git a/src/server/models/types.ts b/src/server/models/types.ts index c51aea5..dcbc0bf 100644 --- a/src/server/models/types.ts +++ b/src/server/models/types.ts @@ -3,5 +3,30 @@ export type ModelResponse = { inputTokens: number; outputTokens: number; modelUsed: string; - provider: 'google' | 'cloudflare'; + provider: string; }; + +export class ProviderRequestError extends Error { + constructor( + public readonly provider: string, + public readonly status: number, + message: string, + ) { + super(`${provider} request failed with ${status}: ${message}`); + this.name = 'ProviderRequestError'; + } +} + +export function providerErrorMessage(errorText: string) { + try { + const parsed = JSON.parse(errorText) as any; + const message = parsed?.error?.message ?? parsed?.message ?? parsed?.error; + if (typeof message === 'string' && message.trim()) { + return message.trim(); + } + } catch { + // Fall back to the provider body below. + } + + return errorText.trim() || 'The provider returned an error.'; +} diff --git a/src/server/routes/api/models.ts b/src/server/routes/api/models.ts index 1a09283..68e41f0 100644 --- a/src/server/routes/api/models.ts +++ b/src/server/routes/api/models.ts @@ -1,19 +1,54 @@ import { Hono } from 'hono'; import { z } from 'zod'; import type { AppEnv } from '@server/env'; -import { listModelConfigs, updateModelConfig } from '@server/db/model-configs'; +import { + createLlmProvider, + deleteLlmProvider, + deleteModelConfig, + findLlmProviderByName, + getResolvedModelConfig, + listLlmProviderSecrets, + listLlmProviders, + listModelConfigs, + updateLlmProvider, + updateModelConfig, + upsertDiscoveredModelConfigs, +} from '@server/db/model-configs'; import { jsonError } from '@server/core/http'; import { getGlobalConfig, updateGlobalConfig } from '@server/core/config'; +import { encryptLlmApiKey, decryptLlmApiKey } from '@server/core/llm-crypto'; +import { llmApiFormats } from '@shared/schema'; +import { reviewWithCloudflare } from '@server/models/cloudflare'; +import { reviewWithGoogle } from '@server/models/google'; +import { reviewWithOpenAI } from '@server/models/openai'; +import { reviewWithAnthropic } from '@server/models/anthropic'; +import { listProviderModels } from '@server/models/catalog'; +import { ProviderRequestError } from '@server/models/types'; -const providerSchema = z.enum(['google', 'cloudflare']); +const apiFormatSchema = z.enum(llmApiFormats); const positiveIntegerSchema = z.number().int().positive().finite(); const modelIdSchema = z.string().trim().min(1); +const optionalUrlSchema = z.string().trim().url().nullable().optional(); +const providerIdSchema = z.string().uuid(); + +const providerCreateSchema = z.object({ + name: z.string().trim().min(1), + apiFormat: apiFormatSchema, + baseUrl: optionalUrlSchema, + apiKey: z.string().optional(), + enabled: z.boolean().default(true), +}).strict(); + +const providerUpdateSchema = providerCreateSchema.extend({ + clearApiKey: z.boolean().optional(), +}).strict(); const modelConfigUpdateSchema = z.object({ + providerId: providerIdSchema, + modelName: z.string().trim().min(1), rpm: positiveIntegerSchema, tpm: positiveIntegerSchema, rpd: positiveIntegerSchema, - provider: providerSchema, }).strict(); const globalModelConfigSchema = z.object({ @@ -31,14 +66,117 @@ const globalModelConfigSchema = z.object({ .optional(), }).strict(); +function normalizedBaseUrl(apiFormat: z.infer, baseUrl?: string | null) { + if (apiFormat === 'cloudflare-workers-ai') return null; + if (baseUrl) return baseUrl.replace(/\/+$/, ''); + if (apiFormat === 'gemini') return 'https://generativelanguage.googleapis.com/v1beta'; + if (apiFormat === 'anthropic') return 'https://api.anthropic.com/v1'; + return 'https://api.openai.com/v1'; +} + +async function encryptedApiKeyFromBody(env: AppEnv['Bindings'], apiKey?: string, clearApiKey?: boolean) { + if (clearApiKey) return null; + if (apiKey === undefined) return undefined; + const trimmed = apiKey.trim(); + if (!trimmed) return undefined; + return encryptLlmApiKey(env, trimmed); +} + +function isEncryptionConfigError(error: unknown) { + return error instanceof Error && error.message.includes('LLM_CONFIG_ENCRYPTION_KEY'); +} + +function isUniqueNameError(error: unknown) { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error as { code?: string }).code === '23505', + ); +} + +function readModelIdParam(value: string) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function providerErrorStatus(error: ProviderRequestError) { + return error.status >= 500 ? 502 : error.status; +} + +function optionalEnv(value: () => string) { + try { + const resolved = value().trim(); + return resolved.length > 0 ? resolved : undefined; + } catch { + return undefined; + } +} + +async function syncProviderModelCatalog(env: AppEnv['Bindings']) { + const providers = await listLlmProviderSecrets(env); + const syncErrors: Array<{ providerId: string; providerName: string; error: string }> = []; + + await Promise.all(providers.map(async (provider) => { + if (!provider.enabled) { + return; + } + if (provider.apiFormat !== 'cloudflare-workers-ai' && !provider.encryptedApiKey) { + return; + } + + try { + const apiKey = provider.encryptedApiKey + ? await decryptLlmApiKey(env, provider.encryptedApiKey) + : undefined; + const modelNames = await listProviderModels({ + apiFormat: provider.apiFormat, + baseUrl: provider.baseUrl, + apiKey, + cloudflareAccountId: optionalEnv(() => env.CF_ACCOUNT_ID), + cloudflareApiToken: optionalEnv(() => env.CF_API_TOKEN), + }); + await upsertDiscoveredModelConfigs(env, { + providerId: provider.id, + providerName: provider.name, + apiFormat: provider.apiFormat, + modelNames, + }); + } catch (error) { + syncErrors.push({ + providerId: provider.id, + providerName: provider.name, + error: error instanceof Error ? error.message : 'Could not refresh provider models.', + }); + } + })); + + return syncErrors; +} + export function createModelsRouter() { const app = new Hono(); app.get('/', async (c) => { - const configs = await listModelConfigs(c.env); - return c.json({ configs }); + const [providers, configs] = await Promise.all([ + listLlmProviders(c.env), + listModelConfigs(c.env), + ]); + return c.json({ providers, configs }); }); - + + app.post('/sync', async (c) => { + const syncErrors = await syncProviderModelCatalog(c.env); + const [providers, configs] = await Promise.all([ + listLlmProviders(c.env), + listModelConfigs(c.env), + ]); + return c.json({ providers, configs, syncErrors }); + }); + app.get('/global', async (c) => { const config = await getGlobalConfig(c.env); return c.json({ config }); @@ -55,8 +193,160 @@ export function createModelsRouter() { return c.json({ ok: true }); }); + app.post('/providers', async (c) => { + const parsed = providerCreateSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return jsonError('Invalid provider config.', 400); + } + + const input = parsed.data; + const existing = await findLlmProviderByName(c.env, input.name); + if (existing) { + return jsonError(`Provider ${input.name} already exists. Update the existing provider instead.`, 409); + } + + let encryptedApiKey: string | null = null; + try { + encryptedApiKey = input.apiFormat === 'cloudflare-workers-ai' + ? null + : (await encryptedApiKeyFromBody(c.env, input.apiKey)) ?? null; + } catch (error) { + if (isEncryptionConfigError(error)) { + return jsonError(error instanceof Error ? error.message : 'LLM encryption is not configured.', 400); + } + throw error; + } + + let provider; + try { + provider = await createLlmProvider(c.env, { + name: input.name, + apiFormat: input.apiFormat, + baseUrl: normalizedBaseUrl(input.apiFormat, input.baseUrl), + encryptedApiKey, + enabled: input.enabled, + }); + } catch (error) { + if (isUniqueNameError(error)) { + return jsonError(`Provider ${input.name} already exists. Update the existing provider instead.`, 409); + } + throw error; + } + + return c.json({ provider }, 201); + }); + + app.patch('/providers/:id', async (c) => { + const id = c.req.param('id'); + if (!providerIdSchema.safeParse(id).success) { + return jsonError('Invalid provider id.', 400); + } + + const parsed = providerUpdateSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return jsonError('Invalid provider config.', 400); + } + + const input = parsed.data; + let encryptedApiKey: string | null | undefined; + try { + encryptedApiKey = input.apiFormat === 'cloudflare-workers-ai' + ? null + : await encryptedApiKeyFromBody(c.env, input.apiKey, input.clearApiKey); + } catch (error) { + if (isEncryptionConfigError(error)) { + return jsonError(error instanceof Error ? error.message : 'LLM encryption is not configured.', 400); + } + throw error; + } + + let provider; + try { + provider = await updateLlmProvider(c.env, id, { + name: input.name, + apiFormat: input.apiFormat, + baseUrl: normalizedBaseUrl(input.apiFormat, input.baseUrl), + ...(encryptedApiKey !== undefined ? { encryptedApiKey } : {}), + enabled: input.enabled, + }); + } catch (error) { + if (isUniqueNameError(error)) { + return jsonError(`Provider ${input.name} already exists. Choose a different provider name.`, 409); + } + throw error; + } + + if (!provider) return jsonError('Provider not found.', 404); + return c.json({ provider }); + }); + + app.delete('/providers/:id', async (c) => { + const id = c.req.param('id'); + if (!providerIdSchema.safeParse(id).success) { + return jsonError('Invalid provider id.', 400); + } + + const result = await deleteLlmProvider(c.env, id); + if (!result.deleted) { + return jsonError(result.reason ?? 'Provider not found.', result.reason ? 409 : 404); + } + return c.json({ ok: true }); + }); + + app.post('/:id/test', async (c) => { + const modelId = readModelIdParam(c.req.param('id')); + const parsedModelId = modelIdSchema.safeParse(modelId); + if (!parsedModelId.success) { + return jsonError('Invalid model id.', 400); + } + + const config = await getResolvedModelConfig(c.env, parsedModelId.data); + if (!config) return jsonError('Model not found.', 404); + if (!config.providerEnabled) return jsonError('Provider is disabled.', 400); + + try { + const input = { + systemPrompt: 'Return only JSON.', + userPrompt: 'Return {"ok":true}.', + }; + let response; + if (config.apiFormat === 'cloudflare-workers-ai') { + response = await reviewWithCloudflare(c.env, config.modelName, input, undefined, config.providerName); + } else { + if (!config.encryptedApiKey) { + return jsonError(`Provider ${config.providerName} does not have a saved API key.`, 400); + } + const apiKey = await decryptLlmApiKey(c.env, config.encryptedApiKey); + if (config.apiFormat === 'gemini') { + response = await reviewWithGoogle({ apiKey, baseUrl: config.baseUrl, providerName: config.providerName }, config.modelName, input); + } else if (config.apiFormat === 'openai') { + response = await reviewWithOpenAI({ + apiKey, + baseUrl: config.baseUrl || 'https://api.openai.com/v1', + providerName: config.providerName, + }, config.modelName, input); + } else { + response = await reviewWithAnthropic({ apiKey, baseUrl: config.baseUrl, providerName: config.providerName }, config.modelName, input); + } + } + + return c.json({ + ok: true, + modelUsed: response.modelUsed, + provider: response.provider, + inputTokens: response.inputTokens, + outputTokens: response.outputTokens, + }); + } catch (error) { + return jsonError( + error instanceof Error ? error.message : 'Connection test failed.', + error instanceof ProviderRequestError ? providerErrorStatus(error) : 502, + ); + } + }); + app.post('/:id', async (c) => { - const modelId = c.req.param('id'); + const modelId = readModelIdParam(c.req.param('id')); const parsedModelId = modelIdSchema.safeParse(modelId); if (!parsedModelId.success) { return jsonError('Invalid model id.', 400); @@ -67,12 +357,25 @@ export function createModelsRouter() { if (!parsed.success) { return jsonError('Invalid model config.', 400); } - - await updateModelConfig(c.env, { + + const saved = await updateModelConfig(c.env, { modelId: parsedModelId.data, ...parsed.data, }); - + + if (!saved) return jsonError('Provider not found.', 404); + return c.json({ ok: true, config: saved }); + }); + + app.delete('/:id', async (c) => { + const modelId = readModelIdParam(c.req.param('id')); + const parsedModelId = modelIdSchema.safeParse(modelId); + if (!parsedModelId.success) { + return jsonError('Invalid model id.', 400); + } + + const deleted = await deleteModelConfig(c.env, parsedModelId.data); + if (!deleted) return jsonError('Model not found.', 404); return c.json({ ok: true }); }); diff --git a/src/server/services/model.ts b/src/server/services/model.ts index a06b604..6cb36c9 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -1,6 +1,8 @@ import type { AppBindings } from '../env'; import { reviewWithGoogle } from '../models/google'; import { reviewWithCloudflare } from '../models/cloudflare'; +import { reviewWithOpenAI } from '../models/openai'; +import { reviewWithAnthropic } from '../models/anthropic'; import { buildFileReviewPrompts } from '../prompts/file-review'; import { buildSummaryPrompt, SUMMARY_SYSTEM_PROMPT } from '../prompts/summary'; import { parseFileReviewResponse } from '../core/model-output'; @@ -10,15 +12,15 @@ import type { TokenTracker } from '../core/token-tracker'; import type { ModelResponse } from '../models/types'; import { logger } from '../core/logger'; import { normalizeModelId } from '@shared/schema'; +import { getResolvedModelConfig, type ResolvedModelConfig } from '@server/db/model-configs'; +import { decryptLlmApiKey } from '@server/core/llm-crypto'; -const DEFAULT_GOOGLE_FALLBACK = 'gemma-4-31b-it'; const PROVIDER_UNAVAILABLE_TTL_SECONDS = 24 * 60 * 60; const COMPACT_REVIEW_PROMPT_LINE_CAP = 400; const MODEL_ALIASES: Record = { 'gemma-4-31b': 'gemma-4-31b-it', 'gemma-4-26b': 'gemma-4-26b-a4b-it', }; -type ModelProvider = 'cloudflare' | 'google'; export class RetryableModelError extends Error { readonly retryable = true; @@ -40,14 +42,6 @@ export function isRetryableModelError(error: unknown) { return Boolean(error && typeof error === 'object' && 'retryable' in error && error.retryable === true); } -function isCloudflareModel(model: string) { - return model.startsWith('@cf/'); -} - -function getModelProvider(model: string): ModelProvider { - return isCloudflareModel(model) ? 'cloudflare' : 'google'; -} - function normalizeModel(model: string) { return normalizeModelId(MODEL_ALIASES[model] ?? model); } @@ -96,26 +90,26 @@ export class ModelService { private options: { jobId?: string } = {}, ) {} - private providerUnavailableKey(provider: ModelProvider) { - return this.options.jobId ? `jobs:${this.options.jobId}:provider-unavailable:${provider}` : null; + private providerUnavailableKey(providerId: string) { + return this.options.jobId ? `jobs:${this.options.jobId}:provider-unavailable:${providerId}` : null; } - private async isProviderUnavailable(provider: ModelProvider) { - const key = this.providerUnavailableKey(provider); + private async isProviderUnavailable(providerId: string) { + const key = this.providerUnavailableKey(providerId); if (!key) return false; try { return (await this.env.APP_KV.get(key)) !== null; } catch (error) { - logger.warn(`Failed to read unavailable provider marker for ${provider}`, { + logger.warn(`Failed to read unavailable provider marker for ${providerId}`, { error: error instanceof Error ? error.message : String(error), }); return false; } } - private async markProviderUnavailable(provider: ModelProvider, reason: string) { - const key = this.providerUnavailableKey(provider); + private async markProviderUnavailable(providerId: string, reason: string) { + const key = this.providerUnavailableKey(providerId); if (!key) return; try { @@ -128,7 +122,7 @@ export class ModelService { { expirationTtl: PROVIDER_UNAVAILABLE_TTL_SECONDS }, ); } catch (error) { - logger.warn(`Failed to write unavailable provider marker for ${provider}`, { + logger.warn(`Failed to write unavailable provider marker for ${providerId}`, { error: error instanceof Error ? error.message : String(error), }); } @@ -165,23 +159,71 @@ export class ModelService { const chain = uniqueModels([selectedModel, ...fallbackModels]); selectedModel = chain[0] ?? 'gemma-4-31b-it'; fallbackModels = chain.slice(1); - if (chain.length > 0 && chain.every(isCloudflareModel)) { - fallbackModels = [...fallbackModels, DEFAULT_GOOGLE_FALLBACK]; - } return { primary: selectedModel, fallbacks: fallbackModels }; } - private async callModel(model: string, input: { systemPrompt: string; userPrompt: string }): Promise { - model = normalizeModel(model); - // Determine provider based on model name - // Cloudflare models start with @cf/ - if (model.startsWith('@cf/')) { - return await reviewWithCloudflare(this.env, model, input, this.tracker); - } else { - // Default to Google for gemma/gemini - return await reviewWithGoogle(this.env, model, input, this.tracker); + private async resolveModel(model: string) { + const normalized = normalizeModel(model); + const resolved = await getResolvedModelConfig(this.env, normalized); + if (!resolved) { + throw new Error(`Model ${normalized} is not configured. Add it in Settings before using it in a route.`); + } + + if (!resolved.providerEnabled) { + throw new Error(`Provider ${resolved.providerName} is disabled.`); + } + + return resolved; + } + + private async decryptApiKey(config: ResolvedModelConfig) { + if (!config.encryptedApiKey) { + throw new Error(`Provider ${config.providerName} does not have a saved API key.`); } + return decryptLlmApiKey(this.env, config.encryptedApiKey); + } + + private async callResolvedModel( + config: ResolvedModelConfig, + input: { systemPrompt: string; userPrompt: string }, + ): Promise { + if (config.apiFormat === 'cloudflare-workers-ai') { + return reviewWithCloudflare(this.env, config.modelName, input, this.tracker, config.providerName); + } + + if (config.apiFormat === 'gemini') { + return reviewWithGoogle( + { apiKey: await this.decryptApiKey(config), baseUrl: config.baseUrl, providerName: config.providerName }, + config.modelName, + input, + this.tracker, + ); + } + + if (config.apiFormat === 'openai') { + return reviewWithOpenAI( + { + apiKey: await this.decryptApiKey(config), + baseUrl: config.baseUrl || 'https://api.openai.com/v1', + providerName: config.providerName, + }, + config.modelName, + input, + this.tracker, + ); + } + + return reviewWithAnthropic( + { apiKey: await this.decryptApiKey(config), baseUrl: config.baseUrl, providerName: config.providerName }, + config.modelName, + input, + this.tracker, + ); + } + + private async callModel(model: string, input: { systemPrompt: string; userPrompt: string }): Promise { + return this.callResolvedModel(await this.resolveModel(model), input); } async reviewFile(params: { @@ -213,9 +255,19 @@ export class ModelService { let lastTransientError: unknown; let sawTransientFailure = false; for (const currentModel of modelsToTry) { - const provider = getModelProvider(currentModel); - if (provider === 'cloudflare' && await this.isProviderUnavailable('cloudflare')) { - logger.warn(`Skipping Cloudflare model ${currentModel} because Cloudflare AI allocation is unavailable for job ${this.options.jobId ?? 'unknown'}`); + let resolved: ResolvedModelConfig; + try { + resolved = await this.resolveModel(currentModel); + } catch (error) { + lastError = error; + logger.warn(`Model ${currentModel} could not be resolved`, { + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + + if (resolved.apiFormat === 'cloudflare-workers-ai' && await this.isProviderUnavailable(resolved.providerId)) { + logger.warn(`Skipping ${resolved.providerName} model ${currentModel} because the provider is unavailable for job ${this.options.jobId ?? 'unknown'}`); continue; } @@ -224,7 +276,7 @@ export class ModelService { while (attempts < maxAttempts) { try { - const response = await this.callModel(currentModel, { systemPrompt, userPrompt }); + const response = await this.callResolvedModel(resolved, { systemPrompt, userPrompt }); if (this.tracker) { this.tracker.record(response.modelUsed, response.inputTokens, response.outputTokens); @@ -245,8 +297,8 @@ export class ModelService { lastTransientError = error; } attempts++; - if (isCloudflareModel(currentModel) && isCloudflareAllocationError(error)) { - await this.markProviderUnavailable('cloudflare', error instanceof Error ? error.message : String(error)); + if (resolved.apiFormat === 'cloudflare-workers-ai' && isCloudflareAllocationError(error)) { + await this.markProviderUnavailable(resolved.providerId, error instanceof Error ? error.message : String(error)); } const isRateLimit = isGoogleRateLimitError(error); @@ -293,13 +345,24 @@ export class ModelService { let lastTransientError: unknown; let sawTransientFailure = false; for (const currentModel of modelsToTry) { - if (isCloudflareModel(currentModel) && await this.isProviderUnavailable('cloudflare')) { - logger.warn(`Skipping Cloudflare summary model ${currentModel} because Cloudflare AI allocation is unavailable for job ${this.options.jobId ?? 'unknown'}`); + let resolved: ResolvedModelConfig; + try { + resolved = await this.resolveModel(currentModel); + } catch (error) { + lastError = error; + logger.warn(`Summary model ${currentModel} could not be resolved`, { + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + + if (resolved.apiFormat === 'cloudflare-workers-ai' && await this.isProviderUnavailable(resolved.providerId)) { + logger.warn(`Skipping ${resolved.providerName} summary model ${currentModel} because the provider is unavailable for job ${this.options.jobId ?? 'unknown'}`); continue; } try { - const response = await this.callModel(currentModel, { + const response = await this.callResolvedModel(resolved, { systemPrompt: SUMMARY_SYSTEM_PROMPT, userPrompt: buildSummaryPrompt(params), }); @@ -315,8 +378,8 @@ export class ModelService { sawTransientFailure = true; lastTransientError = error; } - if (isCloudflareModel(currentModel) && isCloudflareAllocationError(error)) { - await this.markProviderUnavailable('cloudflare', error instanceof Error ? error.message : String(error)); + if (resolved.apiFormat === 'cloudflare-workers-ai' && isCloudflareAllocationError(error)) { + await this.markProviderUnavailable(resolved.providerId, error instanceof Error ? error.message : String(error)); } logger.warn(`Summary model ${currentModel} failed`, { error: error instanceof Error ? error.message : String(error) }); } diff --git a/src/server/worker-env.d.ts b/src/server/worker-env.d.ts index 664f4e6..1a20668 100644 --- a/src/server/worker-env.d.ts +++ b/src/server/worker-env.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types ./src/server/worker-env.d.ts` (hash: 2cf95a373086a6483897fb06140fae41) +// Generated by Wrangler by running `wrangler types ./src/server/worker-env.d.ts` (hash: 63b433e2d7525f4fc91fc4ed25ea92e2) // Runtime types generated with workerd@1.20260521.1 2026-04-16 nodejs_compat interface __BaseEnv_Env { APP_KV: KVNamespace; @@ -19,7 +19,7 @@ interface __BaseEnv_Env { GITHUB_APP_WEBHOOK_SECRET: string; GITHUB_CLIENT_ID: string; GITHUB_CLIENT_SECRET: string; - GEMINI_API_KEY: string; + LLM_CONFIG_ENCRYPTION_KEY: string; CF_API_TOKEN: string; CF_ACCOUNT_ID: string; } @@ -34,7 +34,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/src/shared/api.ts b/src/shared/api.ts index 14671de..4bbfd25 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -70,5 +70,7 @@ export type DlqResponse = { }; export type ModelConfigsResponse = { + providers: import('./schema').LlmProvider[]; configs: import('./schema').ModelConfig[]; + syncErrors?: Array<{ providerId: string; providerName: string; error: string }>; }; diff --git a/src/shared/schema.ts b/src/shared/schema.ts index e9cf80d..e88bb4e 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -6,6 +6,7 @@ export const fileStatuses = ['pending', 'done', 'skipped', 'failed'] as const; export const reviewVerdicts = ['approve', 'comment'] as const; export const reviewSeverities = ['P0', 'P1', 'P2', 'P3', 'nit'] as const; export const reviewCategories = ['security', 'bugs', 'performance', 'correctness', 'quality'] as const; // Keeping for DB compatibility but will deprecate usage in prompts +export const llmApiFormats = ['openai', 'anthropic', 'gemini', 'cloudflare-workers-ai'] as const; export const dateStringSchema = z.union([z.string(), z.date()]).transform((d) => (d instanceof Date ? d.toISOString() : d)); export const coerceNumberSchema = z.coerce.number(); @@ -338,15 +339,31 @@ export type JobSummary = z.infer; export type FileReviewRecord = z.infer; export type JobDetail = z.infer; export type RepoConfigRecord = z.infer; +export const llmProviderSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + apiFormat: z.enum(llmApiFormats), + baseUrl: z.string().nullable(), + enabled: z.boolean(), + hasApiKey: z.boolean(), + createdAt: dateStringSchema, + updatedAt: dateStringSchema, +}); + export const modelConfigSchema = z.object({ modelId: z.string(), + providerId: z.string().uuid(), + providerName: z.string(), + apiFormat: z.enum(llmApiFormats), + modelName: z.string(), rpm: z.number().int(), tpm: z.number().int(), rpd: z.number().int(), - provider: z.string(), updatedAt: dateStringSchema, }); +export type LlmApiFormat = z.infer['apiFormat']; +export type LlmProvider = z.infer; export type ModelConfig = z.infer; export type StatsPayload = z.infer; diff --git a/test/api.spec.ts b/test/api.spec.ts index ce29724..1d2fe94 100644 --- a/test/api.spec.ts +++ b/test/api.spec.ts @@ -10,11 +10,12 @@ import type { AuthSessionResponse, JobDetailResponse, JobsResponse, + ModelConfigsResponse, RepoConfigsResponse, StatsResponse, UpdatesEmailResponse, } from '@shared/api'; -import { createTestEnv } from './helpers'; +import { createTestEnv, saveTestProviderApiKey } from './helpers'; import { vi } from 'vitest'; function mockGitHubProfile(login = 'devarshishimpi') { @@ -384,6 +385,155 @@ describe('Dashboard API Suite', () => { expect(response.status).toBe(400); }); + it('returns model configs without refreshing remote provider catalogs', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + await saveTestProviderApiKey(env); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('unexpected catalog fetch')); + fetchSpy.mockClear(); + + const response = await app.request('/api/models', { + headers: { Cookie: `codra_session=${token}` }, + }, env); + + expect(response.status).toBe(200); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('refreshes provider model catalogs on the explicit sync endpoint', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + await saveTestProviderApiKey(env); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url.includes('/ai/models/search')) { + return Response.json({ + success: false, + errors: [{ code: 10000, message: 'Authentication error' }], + messages: [], + result: null, + }, { status: 403 }); + } + return Response.json({ + models: [ + { + name: 'models/gemini-2.5-flash', + supportedGenerationMethods: ['generateContent'], + }, + ], + }); + }); + + const response = await app.request('/api/models/sync', { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + 'content-type': 'application/json', + }, + }, env); + + expect(response.status).toBe(200); + const data = await response.json() as ModelConfigsResponse; + expect(data.configs.some(config => config.modelName === 'gemini-2.5-flash')).toBe(true); + expect(data.configs.some(config => config.providerName === 'Cloudflare' && config.modelName === '@cf/openai/gpt-oss-120b')).toBe(true); + expect(data.syncErrors).toEqual([]); + }); + + it('tests models whose ids contain URL path separators', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + const modelId = '@cf/zai-org/glm-4.7-flash'; + + const response = await app.request(`/api/models/${encodeURIComponent(modelId)}/test`, { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + }, + }, env); + + expect(response.status).toBe(200); + const data = await response.json() as { modelUsed: string; provider: string }; + expect(data.modelUsed).toBe(modelId); + expect(data.provider).toBe('Cloudflare'); + }); + + it('returns provider status codes for model test failures', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + await saveTestProviderApiKey(env); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ + error: { + code: 429, + message: 'Quota exceeded. Please retry later.', + status: 'RESOURCE_EXHAUSTED', + }, + }, { status: 429 })); + + const response = await app.request('/api/models/gemma-4-31b-it/test', { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + }, + }, env); + + expect(response.status).toBe(429); + const data = await response.json() as { error: string }; + expect(data.error).toContain('Quota exceeded'); + expect(data.error).not.toContain('"details"'); + }); + + it('reports local Cloudflare Workers AI binding limitations clearly', async () => { + const env = createTestEnv({ + AI: { + async run() { + throw new Error('Binding AI needs to be run remotely'); + }, + } as any, + }); + const token = await getAuthCookie(env); + + const response = await app.request(`/api/models/${encodeURIComponent('@cf/zai-org/glm-4.7-flash')}/test`, { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + }, + }, env); + + expect(response.status).toBe(400); + const data = await response.json() as { error: string }; + expect(data.error).toContain('Cloudflare Workers AI is not available in local Wrangler'); + }); + + it('maps upstream provider server errors to bad gateway after retry', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + await saveTestProviderApiKey(env); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ + error: { + code: 500, + message: 'Internal error encountered.', + }, + }, { status: 500 })); + fetchMock.mockClear(); + + const response = await app.request('/api/models/gemma-4-31b-it/test', { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + }, + }, env); + + expect(response.status).toBe(502); + expect(fetchMock).toHaveBeenCalledTimes(2); + const data = await response.json() as { error: string }; + expect(data.error).toContain('Internal error encountered.'); + }); + it('rejects invalid global model config writes', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); diff --git a/test/helpers.ts b/test/helpers.ts index 440fd50..5a8e0be 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,4 +1,6 @@ import type { AppBindings } from '@server/env'; +import { encryptLlmApiKey } from '@server/core/llm-crypto'; +import { queryRows } from '@server/db/client'; export class MemoryKV { private readonly store = new Map(); @@ -100,7 +102,7 @@ export function createTestEnv(overrides: Partial = {}): AppBindings AUTH_CALLBACK_URL: requiredEnv('AUTH_CALLBACK_URL'), APP_URL: requiredEnv('APP_URL'), DASHBOARD_ALLOWED_USERS: requiredEnv('DASHBOARD_ALLOWED_USERS'), - get GEMINI_API_KEY() { return unusedEnv('GEMINI_API_KEY'); }, + LLM_CONFIG_ENCRYPTION_KEY: 'test-llm-config-encryption-key', BOT_USERNAME: requiredEnv('BOT_USERNAME'), get ENVIRONMENT() { return unusedEnv('ENVIRONMENT'); }, get CF_API_TOKEN() { return unusedEnv('CF_API_TOKEN'); }, @@ -110,6 +112,19 @@ export function createTestEnv(overrides: Partial = {}): AppBindings }; } +export async function saveTestProviderApiKey(env: AppBindings, providerName = 'Google', apiKey = 'test-key') { + const encrypted = await encryptLlmApiKey(env, apiKey); + await queryRows( + env, + ` + UPDATE llm_providers + SET encrypted_api_key = $1, enabled = TRUE, updated_at = now() + WHERE name = $2 + `, + [encrypted, providerName], + ); +} + /** * Generates a mock Unified Diff string for testing. */ diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index 72630e9..e134e2c 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { isRetryableModelError, ModelService } from '@server/services/model'; import { reviewWithCloudflare } from '@server/models/cloudflare'; import { reviewWithGoogle } from '@server/models/google'; -import { createTestEnv } from './helpers'; +import { createTestEnv, saveTestProviderApiKey } from './helpers'; import { defaultRepoConfig } from '@shared/schema'; describe('ModelService', () => { @@ -169,7 +169,7 @@ describe('ModelService', () => { ); const response = await reviewWithGoogle( - { GEMINI_API_KEY: 'test-key' }, + { apiKey: 'test-key' }, 'gemma-4-31b-it', { systemPrompt: 'system', userPrompt: 'user' }, ); @@ -213,6 +213,18 @@ describe('ModelService', () => { { status: 500, headers: { 'content-type': 'application/json' } }, ), ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + code: 500, + message: 'Internal error encountered.', + status: 'INTERNAL', + }, + }), + { status: 500, headers: { 'content-type': 'application/json' } }, + ), + ) .mockResolvedValueOnce( new Response( JSON.stringify({ @@ -237,8 +249,8 @@ describe('ModelService', () => { }; }, } as any, - GEMINI_API_KEY: 'test-key', }); + await saveTestProviderApiKey(env); const service = new ModelService(env); const response = await service.reviewFile({ @@ -264,9 +276,10 @@ describe('ModelService', () => { totalLineCount: 1, }); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(3); expect(String(fetchMock.mock.calls[0][0])).toContain('/models/gemma-4-31b-it:generateContent'); - expect(String(fetchMock.mock.calls[1][0])).toContain('/models/gemma-4-26b-a4b-it:generateContent'); + expect(String(fetchMock.mock.calls[1][0])).toContain('/models/gemma-4-31b-it:generateContent'); + expect(String(fetchMock.mock.calls[2][0])).toContain('/models/gemma-4-26b-a4b-it:generateContent'); expect(cloudflareCalls).toBe(0); expect(response.modelUsed).toBe('gemma-4-26b-a4b-it'); }); @@ -342,8 +355,8 @@ describe('ModelService', () => { throw new Error('Cloudflare daily free allocation exhausted (4006)'); }, } as any, - GEMINI_API_KEY: 'test-key', }); + await saveTestProviderApiKey(env); const service = new ModelService(env, undefined, { jobId: 'job-provider-skip' }); const file = { path: 'src/app.ts', @@ -394,7 +407,8 @@ describe('ModelService', () => { { status: 200, headers: { 'content-type': 'application/json' } }, ); }); - const env = createTestEnv({ GEMINI_API_KEY: 'test-key' }); + const env = createTestEnv(); + await saveTestProviderApiKey(env); const service = new ModelService(env); const largeFile = { path: 'src/large.ts', @@ -453,7 +467,8 @@ describe('ModelService', () => { { status: 200, headers: { 'content-type': 'application/json' } }, ); }); - const env = createTestEnv({ GEMINI_API_KEY: 'test-key' }); + const env = createTestEnv(); + await saveTestProviderApiKey(env); const service = new ModelService(env); const largeFile = { path: 'src/large.ts', diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 54e9805..3dbb3e7 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -11,7 +11,7 @@ declare interface Env { APP_PRIVATE_KEY: string; GITHUB_APP_ID: string; GITHUB_APP_WEBHOOK_SECRET: string; - GEMINI_API_KEY: string; + LLM_CONFIG_ENCRYPTION_KEY: string; GEMINI_MODEL: string; BOT_USERNAME: string; ENVIRONMENT: string; diff --git a/wrangler.jsonc b/wrangler.jsonc index dcd3b96..80ceecc 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -83,7 +83,7 @@ "GITHUB_APP_WEBHOOK_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", - "GEMINI_API_KEY", + "LLM_CONFIG_ENCRYPTION_KEY", "CF_API_TOKEN", "CF_ACCOUNT_ID" ] From ff77850cadcca9fb138d0f463e4b81d330b1113a Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Mon, 25 May 2026 23:46:06 +0530 Subject: [PATCH 2/6] refactor: label management and add provider validation --- .dev.vars.example | 2 + src/client/pages/settings.tsx | 1118 ++++++++++++++++--------------- src/server/core/github.ts | 26 + src/server/core/review.ts | 20 +- src/server/routes/api/models.ts | 19 + src/server/services/github.ts | 5 +- test/api.spec.ts | 54 ++ test/review-flow.spec.ts | 1 + 8 files changed, 699 insertions(+), 546 deletions(-) diff --git a/.dev.vars.example b/.dev.vars.example index c5819bc..da963bf 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -16,6 +16,8 @@ GITHUB_CLIENT_SECRET="REPLACE_WITH_YOUR_CLIENT_SECRET" APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_YOUR_GITHUB_APP_PRIVATE_KEY_CONTENT\n-----END RSA PRIVATE KEY-----" # --- Cloudflare API --- +# Required permissions: Queues Edit for DLQ actions, Workers AI Read for +# Cloudflare model catalog discovery. CF_ACCOUNT_ID="REPLACE_WITH_YOUR_CLOUDFLARE_ACCOUNT_ID" CF_API_TOKEN="REPLACE_WITH_CLOUDFLARE_API_TOKEN" diff --git a/src/client/pages/settings.tsx b/src/client/pages/settings.tsx index df297cf..5a85135 100644 --- a/src/client/pages/settings.tsx +++ b/src/client/pages/settings.tsx @@ -20,6 +20,9 @@ import { CheckCircle2, AlertTriangle, Search, + ChevronDown, + ChevronRight, + X, } from 'lucide-react'; import type { LlmApiFormat, LlmProvider, ModelConfig } from '@shared/schema'; import type { ModelConfigsResponse } from '@shared/api'; @@ -133,6 +136,10 @@ function providerIsReady(provider: Pick) { + return provider.apiFormat === 'cloudflare-workers-ai' || provider.hasApiKey || provider.apiKey.trim().length > 0; +} + function providerStatusLabel(provider: Pick) { if (!provider.enabled) return 'Off'; return providerIsReady(provider) ? 'Ready' : 'Needs key'; @@ -149,6 +156,75 @@ function providerDraftDirty(provider: ProviderDraft, saved?: LlmProvider) { ); } +/* ─── Section wrapper ─────────────────────────────────────────────────────── */ +function SectionCard({ + icon, + title, + description, + action, + children, +}: { + icon: React.ReactNode; + title: string; + description: string; + action?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+
+ + {icon} + +
+

{title}

+

{description}

+
+
+ {action &&
{action}
} +
+ {children} +
+ ); +} + +/* ─── Field label ─────────────────────────────────────────────────────────── */ +function FieldLabel({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/* ─── Stat pill ───────────────────────────────────────────────────────────── */ +function StatPill({ label }: { label: string }) { + return ( + + {label} + + ); +} + +/* ─── Provider status badge ───────────────────────────────────────────────── */ +function ProviderBadge({ provider }: { provider: Pick }) { + const ready = providerIsReady(provider); + return ( + + {ready ? : } + {providerStatusLabel(provider)} + + ); +} + export function SettingsPage() { const [providers, setProviders] = useState([]); const [savedProviders, setSavedProviders] = useState([]); @@ -183,6 +259,7 @@ export function SettingsPage() { const [modelSearch, setModelSearch] = useState(''); const [modelProviderFilter, setModelProviderFilter] = useState('all'); const [expandedModelId, setExpandedModelId] = useState(null); + const [addingModel, setAddingModel] = useState(false); const providerOptions: ProviderOption[] = useMemo( () => providers.map(provider => ({ value: provider.id, label: provider.name })), @@ -258,7 +335,6 @@ export function SettingsPage() { const refreshModelCatalog = async ({ quiet = false }: { quiet?: boolean } = {}) => { if (catalogRefreshing) return; - setCatalogRefreshing(true); setSyncErrors([]); const tid = quiet ? null : toast.loading('Refreshing model catalog...'); @@ -278,9 +354,7 @@ export function SettingsPage() { } catch (e) { const msg = e instanceof Error ? e.message : 'Catalog refresh failed'; setSyncErrors([{ providerId: 'catalog-refresh', providerName: 'Model catalog', error: msg }]); - if (!quiet) { - toast.error('Could not refresh catalog', { id: tid ?? undefined, description: msg }); - } + if (!quiet) toast.error('Could not refresh catalog', { id: tid ?? undefined, description: msg }); } finally { setCatalogRefreshing(false); } @@ -341,6 +415,12 @@ export function SettingsPage() { }; const saveProvider = async (provider: ProviderDraft) => { + if (provider.enabled && !providerHasCredential(provider)) { + setExpandedProviderId(provider.id); + toast.error('Add an API key before enabling this provider.'); + return; + } + setSaving(`provider:${provider.id}`); setError(null); const tid = toast.loading('Saving provider...'); @@ -484,6 +564,7 @@ export function SettingsPage() { setConfigs(current => [...current.filter(item => item.modelId !== config.modelId), config].sort((a, b) => a.modelId.localeCompare(b.modelId))); setSavedConfigs(current => [...current.filter(item => item.modelId !== config.modelId), config].sort((a, b) => a.modelId.localeCompare(b.modelId))); setNewModel(current => ({ ...current, modelId: '', modelName: '' })); + setAddingModel(false); toast.success('Model created', { id: tid }); } catch (e) { const msg = e instanceof Error ? e.message : 'Model creation failed'; @@ -549,619 +630,582 @@ export function SettingsPage() { !selectedProviderNameExists; const configuredProviderCount = providers.filter(providerIsReady).length; - const customProviderCount = providers.filter(isCustomProvider).length; return ( -
+
{error && ( - {error} + {error} )} {syncErrors.length > 0 && ( -
-

Some provider model catalogs could not refresh.

-

- {syncErrors.map(item => `${item.providerName}: ${item.error}`).join(' | ')} +

+

Some provider catalogs could not refresh

+

+ {syncErrors.map(item => `${item.providerName}: ${item.error}`).join(' · ')}

)} -
-
-
- - - -
-

LLM providers

-

- {configuredProviderCount} configured for model discovery and review routing. -

+ {/* ── LLM Providers ──────────────────────────────────────────────────── */} + } + title="LLM Providers" + description={`${configuredProviderCount} of ${providers.length} configured`} + action={ +
+ + +
+ } + > + {/* Add provider form */} + {addingProvider && ( +
+

New provider

+
+
+ Type + setNewProvider(current => ({ ...current, name: e.target.value }))} + /> + {selectedProviderNameExists && ( +

{newProvider.name.trim()} already exists

+ )} +
+
+ Base URL + setNewProvider(current => ({ ...current, baseUrl: e.target.value }))} + /> +
+
+ API Key + setNewProvider(current => ({ ...current, apiKey: e.target.value }))} + /> +
+
+
+
- -
+ )} + {/* Provider list */} {loading ? (
- + + +
+ ) : providers.length === 0 ? ( +
+ No providers configured yet.
) : ( -
-
-
-
- - {configuredProviderCount} ready - - - {providers.length} providers - - - {customProviderCount} custom - - - {configs.length} models - - - {catalogRefreshing - ? 'Refreshing model lists...' - : catalogRefreshedOnce - ? 'Model lists refreshed this session.' - : 'Loaded from the database.'} - -
- -
+ {/* Row */} +
+ {/* Status dot */} + + + {/* Name + meta */} +
+

{provider.name}

+

+ {formatLabel(provider.apiFormat)} + {modelCount > 0 && · {modelCount} model{modelCount !== 1 ? 's' : ''}} +

+
+ + {/* Credential hint */} +

+ {nativeCloudflare + ? 'Worker binding' + : provider.hasApiKey + ? 'Key saved' + : No key + } +

- {addingProvider && ( -
-
- setNewProvider(current => ({ ...current, name: e.target.value }))} - /> - - -
- {selectedProviderNameExists && ( -

- {newProvider.name.trim()} already exists. -

- )} -
- )} - -
- Provider - Type - Models - Credential - Actions -
-
- {providers.map(provider => { - const nativeCloudflare = provider.apiFormat === 'cloudflare-workers-ai'; - const customProvider = isCustomProvider(provider); - const ready = providerIsReady(provider); - const savedProvider = savedProviders.find(saved => saved.id === provider.id); - const dirty = providerDraftDirty(provider, savedProvider); - const modelCount = providerModelCounts.get(provider.id) ?? 0; - const expanded = expandedProviderId === provider.id; - return ( -
-
-
- - {ready ? : } - -
-

{provider.name}

-
- - {providerStatusLabel(provider)} - - {formatLabel(provider.apiFormat)} - {modelCount} {modelCount === 1 ? 'model' : 'models'} -
+ {/* Expanded edit panel */} + {expanded && ( +
+
+ {customProvider && ( + <> +
+ Name + updateProviderDraft(provider.id, { name: e.target.value })} + />
-
- -

{formatLabel(provider.apiFormat)}

-

{modelCount}

-

- {nativeCloudflare ? 'Worker AI binding' : provider.hasApiKey ? 'Saved key hidden' : 'No API key'} -

- -
- - - - {customProvider && ( - - )} -
-
- - {expanded && ( -
- {customProvider && ( -
- - updateProviderDraft(provider.id, { baseUrl: e.target.value || null })} - /> - -
- )} - - {nativeCloudflare ? ( -
- Native provider. Calls use the Worker AI binding configured in Wrangler. -
- ) : ( - - )} +
+ + )} + {nativeCloudflare ? ( +

+ Native provider — calls use the Worker AI binding configured in Wrangler. +

+ ) : ( +
+ API Key + updateProviderDraft(provider.id, { apiKey: e.target.value })} + className="max-w-md" + />
)} -
- ); - })} -
-
+
+
+ )} + + ); + })}
)} -
- -
-
-
- - - -
-

Global model strategy

-

- Account-wide baseline route and file-size tiers. -

-
+ + {/* Catalog status footer */} + {!loading && ( +
+

+ {catalogRefreshing + ? 'Refreshing model lists…' + : catalogRefreshedOnce + ? 'Model lists refreshed this session.' + : 'Loaded from the database.'} +

+ )} + + + {/* ── Global model strategy ───────────────────────────────────────────── */} + } + title="Global model strategy" + description="Account-wide baseline route and file-size tiers" + action={ -
- -
+ } + > +
{!loading && globalConfig ? ( -
- -
+ ) : ( -
+
- +
)}
-
- -
-
-
- - - -
-

Models and usage limits

-

- Codra model IDs, provider model names, and rate metadata. -

-
+ + + {/* ── Models & Usage Limits ────────────────────────────────────────────── */} + } + title="Models & usage limits" + description={`${configs.length} models · provider mappings and rate limits`} + action={ +
+ {dirtyConfigs.length > 0 && ( + + )} +
- -
- - {loading ? ( -
- {[1, 2, 3].map(i => ( -
- -
- ))} -
- ) : ( -
-
-
- - Add custom model -
-
+ } + > + {/* Add model form */} + {addingModel && ( +
+

New model

+
+
+ Codra model ID setNewModel(current => ({ ...current, modelId: e.target.value }))} /> +
+
+ Provider model name setNewModel(current => ({ ...current, modelName: e.target.value }))} /> - setNewModel(current => ({ ...current, providerId }))} + options={providerOptions} + placeholder="Select provider" + /> +
+
+ {(['rpm', 'rpd', 'tpm'] as const).map(field => ( +
+ {field.toUpperCase()} setNewModel(current => ({ ...current, [field]: Number(e.target.value) || 1 }))} /> - ))} - -
+
+ ))}
+
+ +
+
+ )} -
-
- - setModelSearch(e.target.value)} + /> + +
+ updateModel(cfg.modelId, { providerId })} - options={providerOptions} - /> -
+
+ )} + + ); + })} - {filteredConfigs.length === 0 && ( -
- No models match the current filters. -
- )} + {filteredConfigs.length === 0 && ( +
+ No models match the current filters.
-
+ )}
)} -
+
); } diff --git a/src/server/core/github.ts b/src/server/core/github.ts index 7d0ea18..1e2d8df 100644 --- a/src/server/core/github.ts +++ b/src/server/core/github.ts @@ -87,6 +87,10 @@ export type GitHubReviewComment = { body: string; }; +type GitHubIssueLabel = { + name?: string; +}; + function installationCacheKey(installationId: string) { return `install:${installationId}`; } @@ -583,6 +587,28 @@ export class GitHubClient { }); } + async listIssueLabels(owner: string, repo: string, issueNumber: number) { + return withRetry(`listIssueLabels ${owner}/${repo}#${issueNumber}`, async () => { + const response = await this.requestAndCheck(`/repos/${owner}/${repo}/issues/${issueNumber}/labels?per_page=100`); + const labels = (await response.json()) as GitHubIssueLabel[]; + return labels + .map(label => label.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0); + }); + } + + async removeIssueLabelsIfPresent(owner: string, repo: string, issueNumber: number, labels: string[]) { + const currentLabels = await this.listIssueLabels(owner, repo, issueNumber); + const currentByLowerName = new Map(currentLabels.map(label => [label.toLowerCase(), label])); + + for (const label of labels) { + const currentLabel = currentByLowerName.get(label.toLowerCase()); + if (currentLabel) { + await this.removeIssueLabel(owner, repo, issueNumber, currentLabel); + } + } + } + async removeIssueLabel(owner: string, repo: string, issueNumber: number, label: string) { return withRetry(`removeIssueLabel ${owner}/${repo}#${issueNumber} ${label}`, async () => { const response = await this.request( diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 36f2867..d55f275 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -298,9 +298,12 @@ async function resolveQueuedJob( if (prPayload.action === 'closed' && repoConfig.parsedJson.review.labels !== false) { const labels = repoConfig.parsedJson.review.labels; const gh = new GitHubClient(env, installationId); - await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p1); - await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p2); - await gh.removeIssueLabel(prPayload.repository.owner.login, prPayload.repository.name, prPayload.pull_request.number, labels.p3); + await gh.removeIssueLabelsIfPresent( + prPayload.repository.owner.login, + prPayload.repository.name, + prPayload.pull_request.number, + [labels.p1, labels.p2, labels.p3], + ); } } return null; @@ -699,11 +702,12 @@ async function runFinalizePhase( } as const; const label = labelMap[verdictSummary.verdict]; - for (const possibleLabel of [labels.p1, labels.p2, labels.p3]) { - if (possibleLabel !== label.name) { - await github.removeIssueLabel(job.owner, job.repo, job.prNumber, possibleLabel); - } - } + await github.removeIssueLabelsIfPresent( + job.owner, + job.repo, + job.prNumber, + [labels.p1, labels.p2, labels.p3].filter(possibleLabel => possibleLabel !== label.name), + ); await github.ensureLabel(job.owner, job.repo, label.name, label.color); await github.addIssueLabels(job.owner, job.repo, job.prNumber, [label.name]); diff --git a/src/server/routes/api/models.ts b/src/server/routes/api/models.ts index 68e41f0..fe62104 100644 --- a/src/server/routes/api/models.ts +++ b/src/server/routes/api/models.ts @@ -6,6 +6,7 @@ import { deleteLlmProvider, deleteModelConfig, findLlmProviderByName, + getLlmProvider, getResolvedModelConfig, listLlmProviderSecrets, listLlmProviders, @@ -107,6 +108,10 @@ function providerErrorStatus(error: ProviderRequestError) { return error.status >= 500 ? 502 : error.status; } +function providerCanBeEnabled(apiFormat: z.infer, encryptedApiKey: string | null | undefined) { + return apiFormat === 'cloudflare-workers-ai' || Boolean(encryptedApiKey); +} + function optionalEnv(value: () => string) { try { const resolved = value().trim(); @@ -217,6 +222,10 @@ export function createModelsRouter() { throw error; } + if (input.enabled && !providerCanBeEnabled(input.apiFormat, encryptedApiKey)) { + return jsonError(`Provider ${input.name} needs an API key before it can be enabled.`, 400); + } + let provider; try { provider = await createLlmProvider(c.env, { @@ -248,6 +257,9 @@ export function createModelsRouter() { } const input = parsed.data; + const existing = await getLlmProvider(c.env, id); + if (!existing) return jsonError('Provider not found.', 404); + let encryptedApiKey: string | null | undefined; try { encryptedApiKey = input.apiFormat === 'cloudflare-workers-ai' @@ -260,6 +272,13 @@ export function createModelsRouter() { throw error; } + const effectiveEncryptedApiKey = encryptedApiKey !== undefined + ? encryptedApiKey + : existing.encryptedApiKey; + if (input.enabled && !providerCanBeEnabled(input.apiFormat, effectiveEncryptedApiKey)) { + return jsonError(`Provider ${input.name} needs an API key before it can be enabled.`, 400); + } + let provider; try { provider = await updateLlmProvider(c.env, id, { diff --git a/src/server/services/github.ts b/src/server/services/github.ts index d7c2792..f8e9150 100644 --- a/src/server/services/github.ts +++ b/src/server/services/github.ts @@ -36,8 +36,11 @@ export class GitHubService { return this.client.addIssueLabels(owner, repo, prNumber, labels); } + async removeIssueLabelsIfPresent(owner: string, repo: string, prNumber: number, labels: string[]) { + return this.client.removeIssueLabelsIfPresent(owner, repo, prNumber, labels); + } + async removeIssueLabel(owner: string, repo: string, prNumber: number, label: string) { return this.client.removeIssueLabel(owner, repo, prNumber, label); } } - diff --git a/test/api.spec.ts b/test/api.spec.ts index 1d2fe94..eda857e 100644 --- a/test/api.spec.ts +++ b/test/api.spec.ts @@ -400,6 +400,60 @@ describe('Dashboard API Suite', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it('rejects enabling non-Cloudflare providers without a saved API key', async () => { + const env = createTestEnv(); + const token = await getAuthCookie(env); + + const createResponse = await app.request('/api/models/providers', { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + name: 'No Key Provider', + apiFormat: 'openai', + baseUrl: 'https://api.example.com/v1', + enabled: true, + }), + }, env); + expect(createResponse.status).toBe(400); + + const disabledCreateResponse = await app.request('/api/models/providers', { + method: 'POST', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + name: 'Disabled No Key Provider', + apiFormat: 'openai', + baseUrl: 'https://api.example.com/v1', + enabled: false, + }), + }, env); + expect(disabledCreateResponse.status).toBe(201); + const { provider } = await disabledCreateResponse.json() as { provider: { id: string; name: string; apiFormat: string; baseUrl: string } }; + + const updateResponse = await app.request(`/api/models/providers/${provider.id}`, { + method: 'PATCH', + headers: { + Cookie: `codra_session=${token}`, + 'x-requested-with': 'XMLHttpRequest', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + name: provider.name, + apiFormat: provider.apiFormat, + baseUrl: provider.baseUrl, + enabled: true, + }), + }, env); + expect(updateResponse.status).toBe(400); + }); + it('refreshes provider model catalogs on the explicit sync endpoint', async () => { const env = createTestEnv(); const token = await getAuthCookie(env); diff --git a/test/review-flow.spec.ts b/test/review-flow.spec.ts index dece307..4e70657 100644 --- a/test/review-flow.spec.ts +++ b/test/review-flow.spec.ts @@ -28,6 +28,7 @@ vi.mock('@server/services/github', () => { async createReview() { return { id: 456 }; } async ensureLabel() { return {}; } async addIssueLabels() { return {}; } + async removeIssueLabelsIfPresent() { return {}; } async removeIssueLabel() { return {}; } } return { GitHubService: MockGitHubService }; From 0293b366dd9c7f795ccbfe420a7eea928f5be3ae Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 26 May 2026 20:03:13 +0530 Subject: [PATCH 3/6] add: consolidate LLM provider management and make rate limits optional --- db/migrations/001_initial.sql | 1110 ++++++++++------- db/migrations/002_llm.sql | 144 --- scripts/migrate.mjs | 9 + .../features/models/model-chain.tsx | 23 +- src/client/pages/repos.tsx | 37 +- src/client/pages/settings.tsx | 65 +- src/server/core/config.ts | 21 +- src/server/core/review.ts | 6 +- src/server/db/model-configs.ts | 18 +- src/server/routes/api/models.ts | 9 +- src/server/services/model.ts | 22 +- src/shared/schema.ts | 20 +- test/api.spec.ts | 8 +- test/model-service.spec.ts | 9 + test/settings.spec.ts | 8 + 15 files changed, 766 insertions(+), 743 deletions(-) delete mode 100644 db/migrations/002_llm.sql diff --git a/db/migrations/001_initial.sql b/db/migrations/001_initial.sql index 4219560..a3232de 100644 --- a/db/migrations/001_initial.sql +++ b/db/migrations/001_initial.sql @@ -1,170 +1,170 @@ -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -DO $$ BEGIN - CREATE TYPE job_trigger AS ENUM ('auto', 'mention', 'retry'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE job_status AS ENUM ('queued', 'running', 'done', 'failed', 'superseded'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE job_verdict AS ENUM ('approve', 'comment'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE file_status_enum AS ENUM ('pending', 'done', 'skipped', 'failed'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -CREATE TABLE IF NOT EXISTS repositories ( - installation_id BIGINT NOT NULL, - id SERIAL PRIMARY KEY, - owner TEXT NOT NULL, - repo TEXT NOT NULL, - UNIQUE(owner, repo) -); -CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); - -CREATE TABLE IF NOT EXISTS jobs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - retry_of_job_id UUID REFERENCES jobs(id) ON DELETE SET NULL, - - check_run_id BIGINT, - review_id BIGINT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - started_at TIMESTAMPTZ, - finished_at TIMESTAMPTZ, - - repository_id INTEGER NOT NULL REFERENCES repositories(id), - pr_number INTEGER NOT NULL, - total_input_tokens INTEGER DEFAULT 0, - total_output_tokens INTEGER DEFAULT 0, - file_count INTEGER DEFAULT 0, - comment_count INTEGER DEFAULT 0, - overall_confidence_score REAL, - - commit_sha BYTEA NOT NULL, - base_sha BYTEA NOT NULL, - - trigger TEXT NOT NULL CHECK (trigger IN ('auto', 'mention', 'retry')), - status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed', 'superseded')), - verdict TEXT CHECK (verdict IN ('approve', 'comment')), - pr_title TEXT, - pr_author TEXT, - head_ref TEXT, - base_ref TEXT, - summary_model TEXT, - overall_correctness TEXT, - error_msg TEXT, - summary_markdown TEXT, - config_snapshot JSONB COMPRESSION lz4, - steps JSONB COMPRESSION lz4 DEFAULT '[]'::jsonb -) WITH (fillfactor = 90); - -CREATE INDEX IF NOT EXISTS jobs_repo_idx ON jobs (repository_id, pr_number); -CREATE INDEX IF NOT EXISTS jobs_active_idx ON jobs (status) WHERE status IN ('queued', 'running'); -CREATE INDEX IF NOT EXISTS jobs_created_idx ON jobs USING brin (created_at); -CREATE INDEX IF NOT EXISTS jobs_head_sha_idx ON jobs (repository_id, pr_number, commit_sha, trigger); -CREATE INDEX IF NOT EXISTS jobs_correctness_idx ON jobs (overall_correctness); - -CREATE TABLE IF NOT EXISTS file_reviews ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - diff_line_count INTEGER, - input_tokens INTEGER, - output_tokens INTEGER, - duration_ms INTEGER, - confidence_score REAL, - - file_status TEXT NOT NULL CHECK (file_status IN ('pending', 'done', 'skipped', 'failed')), - verdict TEXT CHECK (verdict IN ('approve', 'comment')), - file_path TEXT NOT NULL, - model_used TEXT NOT NULL, - model_provider TEXT, - overall_correctness TEXT, - file_summary TEXT, - error_msg TEXT, - diff_input TEXT COMPRESSION lz4, - raw_ai_output TEXT COMPRESSION lz4 -) WITH (fillfactor = 90); - -CREATE INDEX IF NOT EXISTS file_reviews_job_idx ON file_reviews (job_id); -CREATE INDEX IF NOT EXISTS file_reviews_correctness_idx ON file_reviews (overall_correctness); -CREATE INDEX IF NOT EXISTS file_reviews_provider_idx ON file_reviews (model_provider); - -CREATE TABLE IF NOT EXISTS review_comments ( - file_review_id UUID NOT NULL REFERENCES file_reviews(id) ON DELETE CASCADE, - id BIGSERIAL PRIMARY KEY, - line INTEGER, - position INTEGER, - path TEXT NOT NULL, - severity TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'quality', - title TEXT NOT NULL, - body TEXT COMPRESSION lz4 NOT NULL, - code_suggestion TEXT COMPRESSION lz4 -); -CREATE INDEX IF NOT EXISTS review_comments_file_idx ON review_comments(file_review_id); - -CREATE TABLE IF NOT EXISTS repo_configs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - repository_id INTEGER NOT NULL REFERENCES repositories(id), - - enabled BOOLEAN NOT NULL DEFAULT TRUE, - - main_model TEXT, - parsed_json JSONB, - fallback_models JSONB DEFAULT '[]'::jsonb, - size_overrides JSONB, - UNIQUE (repository_id) -); - -CREATE TABLE IF NOT EXISTS webhook_deliveries ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - received_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - repository_id INTEGER REFERENCES repositories(id), - - delivery_id TEXT NOT NULL UNIQUE, - event_name TEXT NOT NULL, - payload JSONB COMPRESSION lz4 NOT NULL -); - -CREATE INDEX IF NOT EXISTS webhook_deliveries_repo_idx ON webhook_deliveries (repository_id, received_at DESC); - -CREATE TABLE IF NOT EXISTS model_configs ( - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now(), - - rpm INTEGER NOT NULL, - tpm INTEGER NOT NULL, - rpd INTEGER NOT NULL, - - model_id TEXT PRIMARY KEY, - provider TEXT NOT NULL -); - -INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider) -VALUES - ('gemma-4-31b-it', 15, 1000000, 1500, 'google'), - ('gemma-4-26b-a4b-it', 30, 1000000, 1500, 'google'), - ('@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare'), - ('@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare') +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +DO $$ BEGIN + CREATE TYPE job_trigger AS ENUM ('auto', 'mention', 'retry'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE job_status AS ENUM ('queued', 'running', 'done', 'failed', 'superseded'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE job_verdict AS ENUM ('approve', 'comment'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE file_status_enum AS ENUM ('pending', 'done', 'skipped', 'failed'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +CREATE TABLE IF NOT EXISTS repositories ( + installation_id BIGINT NOT NULL, + id SERIAL PRIMARY KEY, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + UNIQUE(owner, repo) +); +CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); + +CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retry_of_job_id UUID REFERENCES jobs(id) ON DELETE SET NULL, + + check_run_id BIGINT, + review_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + + repository_id INTEGER NOT NULL REFERENCES repositories(id), + pr_number INTEGER NOT NULL, + total_input_tokens INTEGER DEFAULT 0, + total_output_tokens INTEGER DEFAULT 0, + file_count INTEGER DEFAULT 0, + comment_count INTEGER DEFAULT 0, + overall_confidence_score REAL, + + commit_sha BYTEA NOT NULL, + base_sha BYTEA NOT NULL, + + trigger TEXT NOT NULL CHECK (trigger IN ('auto', 'mention', 'retry')), + status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed', 'superseded')), + verdict TEXT CHECK (verdict IN ('approve', 'comment')), + pr_title TEXT, + pr_author TEXT, + head_ref TEXT, + base_ref TEXT, + summary_model TEXT, + overall_correctness TEXT, + error_msg TEXT, + summary_markdown TEXT, + config_snapshot JSONB COMPRESSION lz4, + steps JSONB COMPRESSION lz4 DEFAULT '[]'::jsonb +) WITH (fillfactor = 90); + +CREATE INDEX IF NOT EXISTS jobs_repo_idx ON jobs (repository_id, pr_number); +CREATE INDEX IF NOT EXISTS jobs_active_idx ON jobs (status) WHERE status IN ('queued', 'running'); +CREATE INDEX IF NOT EXISTS jobs_created_idx ON jobs USING brin (created_at); +CREATE INDEX IF NOT EXISTS jobs_head_sha_idx ON jobs (repository_id, pr_number, commit_sha, trigger); +CREATE INDEX IF NOT EXISTS jobs_correctness_idx ON jobs (overall_correctness); + +CREATE TABLE IF NOT EXISTS file_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + diff_line_count INTEGER, + input_tokens INTEGER, + output_tokens INTEGER, + duration_ms INTEGER, + confidence_score REAL, + + file_status TEXT NOT NULL CHECK (file_status IN ('pending', 'done', 'skipped', 'failed')), + verdict TEXT CHECK (verdict IN ('approve', 'comment')), + file_path TEXT NOT NULL, + model_used TEXT NOT NULL, + model_provider TEXT, + overall_correctness TEXT, + file_summary TEXT, + error_msg TEXT, + diff_input TEXT COMPRESSION lz4, + raw_ai_output TEXT COMPRESSION lz4 +) WITH (fillfactor = 90); + +CREATE INDEX IF NOT EXISTS file_reviews_job_idx ON file_reviews (job_id); +CREATE INDEX IF NOT EXISTS file_reviews_correctness_idx ON file_reviews (overall_correctness); +CREATE INDEX IF NOT EXISTS file_reviews_provider_idx ON file_reviews (model_provider); + +CREATE TABLE IF NOT EXISTS review_comments ( + file_review_id UUID NOT NULL REFERENCES file_reviews(id) ON DELETE CASCADE, + id BIGSERIAL PRIMARY KEY, + line INTEGER, + position INTEGER, + path TEXT NOT NULL, + severity TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'quality', + title TEXT NOT NULL, + body TEXT COMPRESSION lz4 NOT NULL, + code_suggestion TEXT COMPRESSION lz4 +); +CREATE INDEX IF NOT EXISTS review_comments_file_idx ON review_comments(file_review_id); + +CREATE TABLE IF NOT EXISTS repo_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + repository_id INTEGER NOT NULL REFERENCES repositories(id), + + enabled BOOLEAN NOT NULL DEFAULT TRUE, + + main_model TEXT, + parsed_json JSONB, + fallback_models JSONB DEFAULT '[]'::jsonb, + size_overrides JSONB, + UNIQUE (repository_id) +); + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + repository_id INTEGER REFERENCES repositories(id), + + delivery_id TEXT NOT NULL UNIQUE, + event_name TEXT NOT NULL, + payload JSONB COMPRESSION lz4 NOT NULL +); + +CREATE INDEX IF NOT EXISTS webhook_deliveries_repo_idx ON webhook_deliveries (repository_id, received_at DESC); + +CREATE TABLE IF NOT EXISTS model_configs ( + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + + rpm INTEGER, + tpm INTEGER, + rpd INTEGER, + + model_id TEXT PRIMARY KEY, + provider TEXT NOT NULL +); + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider) +VALUES + ('gemma-4-31b-it', 15, 1000000, 1500, 'google'), + ('gemma-4-26b-a4b-it', 30, 1000000, 1500, 'google'), + ('@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare'), + ('@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare') ON CONFLICT (model_id) DO UPDATE SET rpm = EXCLUDED.rpm, tpm = EXCLUDED.tpm, @@ -220,315 +220,467 @@ WHERE main_model = '@cf/moonshotai/kimi-k2.5' OR parsed_json::text LIKE '%@cf/moonshotai/kimi-k2.5%'; CREATE EXTENSION IF NOT EXISTS pgcrypto; - -CREATE TABLE IF NOT EXISTS repositories ( - installation_id BIGINT NOT NULL, - id SERIAL PRIMARY KEY, - owner TEXT NOT NULL, - repo TEXT NOT NULL, - UNIQUE(owner, repo) -); - -CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); - -CREATE TABLE IF NOT EXISTS review_comments ( - file_review_id UUID NOT NULL REFERENCES file_reviews(id) ON DELETE CASCADE, - id BIGSERIAL PRIMARY KEY, - line INTEGER, - position INTEGER, - path TEXT NOT NULL, - severity TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'quality', - title TEXT NOT NULL, - body TEXT COMPRESSION lz4 NOT NULL, - code_suggestion TEXT COMPRESSION lz4 -); - -CREATE INDEX IF NOT EXISTS review_comments_file_idx ON review_comments(file_review_id); - -DO $$ -DECLARE - has_old_job_repo_columns BOOLEAN; - has_old_repo_config_columns BOOLEAN; - has_old_webhook_repo_columns BOOLEAN; - commit_sha_type TEXT; - base_sha_type TEXT; - null_repository_jobs INTEGER; -BEGIN - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'jobs' - AND column_name IN ('installation_id', 'owner', 'repo') - GROUP BY table_name - HAVING COUNT(*) = 3 - ) INTO has_old_job_repo_columns; - - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'repo_configs' - AND column_name IN ('installation_id', 'owner', 'repo') - GROUP BY table_name - HAVING COUNT(*) = 3 - ) INTO has_old_repo_config_columns; - - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'webhook_deliveries' - AND column_name IN ('owner', 'repo') - GROUP BY table_name - HAVING COUNT(*) = 2 - ) INTO has_old_webhook_repo_columns; - - IF has_old_job_repo_columns THEN - EXECUTE ' - INSERT INTO repositories (installation_id, owner, repo) - SELECT DISTINCT - CASE WHEN installation_id ~ ''^[0-9]+$'' THEN installation_id::bigint ELSE 0 END, - owner, - repo - FROM jobs - WHERE installation_id IS NOT NULL - AND owner IS NOT NULL - AND repo IS NOT NULL - ON CONFLICT (owner, repo) DO UPDATE - SET installation_id = EXCLUDED.installation_id - '; - END IF; - - IF has_old_repo_config_columns THEN - EXECUTE ' - INSERT INTO repositories (installation_id, owner, repo) - SELECT DISTINCT - CASE WHEN installation_id ~ ''^[0-9]+$'' THEN installation_id::bigint ELSE 0 END, - owner, - repo - FROM repo_configs - WHERE installation_id IS NOT NULL - AND owner IS NOT NULL - AND repo IS NOT NULL - ON CONFLICT (owner, repo) DO UPDATE - SET installation_id = EXCLUDED.installation_id - '; - END IF; - - ALTER TABLE jobs ADD COLUMN IF NOT EXISTS repository_id INTEGER; - - IF has_old_job_repo_columns THEN - EXECUTE ' - UPDATE jobs j - SET repository_id = r.id - FROM repositories r - WHERE j.repository_id IS NULL - AND r.owner = j.owner - AND r.repo = j.repo - '; - END IF; - - SELECT data_type - INTO commit_sha_type - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'jobs' - AND column_name = 'commit_sha'; - - IF commit_sha_type IS NOT NULL AND commit_sha_type <> 'bytea' THEN - ALTER TABLE jobs ADD COLUMN IF NOT EXISTS commit_sha_bytea BYTEA; - EXECUTE ' - UPDATE jobs - SET commit_sha_bytea = CASE - WHEN commit_sha ~ ''^[0-9a-fA-F]+$'' AND length(commit_sha) % 2 = 0 THEN decode(commit_sha, ''hex'') - ELSE convert_to(commit_sha, ''UTF8'') - END - WHERE commit_sha_bytea IS NULL - '; - ALTER TABLE jobs DROP COLUMN commit_sha; - ALTER TABLE jobs RENAME COLUMN commit_sha_bytea TO commit_sha; - END IF; - - SELECT data_type - INTO base_sha_type - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'jobs' - AND column_name = 'base_sha'; - - IF base_sha_type IS NOT NULL AND base_sha_type <> 'bytea' THEN - ALTER TABLE jobs ADD COLUMN IF NOT EXISTS base_sha_bytea BYTEA; - EXECUTE ' - UPDATE jobs - SET base_sha_bytea = CASE - WHEN base_sha ~ ''^[0-9a-fA-F]+$'' AND length(base_sha) % 2 = 0 THEN decode(base_sha, ''hex'') - ELSE convert_to(base_sha, ''UTF8'') - END - WHERE base_sha_bytea IS NULL - '; - ALTER TABLE jobs DROP COLUMN base_sha; - ALTER TABLE jobs RENAME COLUMN base_sha_bytea TO base_sha; - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'jobs_repository_id_fkey' - ) THEN - ALTER TABLE jobs - ADD CONSTRAINT jobs_repository_id_fkey - FOREIGN KEY (repository_id) REFERENCES repositories(id); - END IF; - - SELECT COUNT(*) INTO null_repository_jobs FROM jobs WHERE repository_id IS NULL; - IF null_repository_jobs = 0 THEN - ALTER TABLE jobs ALTER COLUMN repository_id SET NOT NULL; - END IF; - - DROP INDEX IF EXISTS jobs_repo_idx; - DROP INDEX IF EXISTS jobs_status_idx; - DROP INDEX IF EXISTS jobs_created_idx; - DROP INDEX IF EXISTS jobs_head_sha_idx; - - CREATE INDEX IF NOT EXISTS jobs_repo_idx ON jobs (repository_id, pr_number); - CREATE INDEX IF NOT EXISTS jobs_active_idx ON jobs (status) WHERE status IN ('queued', 'running'); - CREATE INDEX IF NOT EXISTS jobs_created_idx ON jobs USING brin (created_at); - CREATE INDEX IF NOT EXISTS jobs_head_sha_idx ON jobs (repository_id, pr_number, commit_sha, trigger); - - IF has_old_job_repo_columns THEN - ALTER TABLE jobs DROP COLUMN IF EXISTS installation_id; - ALTER TABLE jobs DROP COLUMN IF EXISTS owner; - ALTER TABLE jobs DROP COLUMN IF EXISTS repo; - END IF; -END $$; - -DO $$ -DECLARE - has_old_columns BOOLEAN; -BEGIN - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'repo_configs' - AND column_name IN ('installation_id', 'owner', 'repo') - GROUP BY table_name - HAVING COUNT(*) = 3 - ) INTO has_old_columns; - - ALTER TABLE repo_configs ADD COLUMN IF NOT EXISTS repository_id INTEGER; - - IF has_old_columns THEN - EXECUTE ' - UPDATE repo_configs rc - SET repository_id = r.id - FROM repositories r - WHERE rc.repository_id IS NULL - AND r.owner = rc.owner - AND r.repo = rc.repo - '; - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'repo_configs_repository_id_fkey' - ) THEN - ALTER TABLE repo_configs - ADD CONSTRAINT repo_configs_repository_id_fkey - FOREIGN KEY (repository_id) REFERENCES repositories(id); - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'repo_configs_repository_id_key' - ) THEN - ALTER TABLE repo_configs ADD CONSTRAINT repo_configs_repository_id_key UNIQUE (repository_id); - END IF; - - ALTER TABLE repo_configs DROP CONSTRAINT IF EXISTS repo_configs_owner_repo_key; - ALTER TABLE repo_configs DROP COLUMN IF EXISTS installation_id; - ALTER TABLE repo_configs DROP COLUMN IF EXISTS owner; - ALTER TABLE repo_configs DROP COLUMN IF EXISTS repo; - ALTER TABLE repo_configs DROP COLUMN IF EXISTS raw_yaml; - ALTER TABLE repo_configs DROP COLUMN IF EXISTS config_missing; -END $$; - -DO $$ -DECLARE - has_old_columns BOOLEAN; -BEGIN - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'webhook_deliveries' - AND column_name IN ('owner', 'repo') - GROUP BY table_name - HAVING COUNT(*) = 2 - ) INTO has_old_columns; - - ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS repository_id INTEGER; - - IF has_old_columns THEN - EXECUTE ' - UPDATE webhook_deliveries wd - SET repository_id = r.id - FROM repositories r - WHERE wd.repository_id IS NULL - AND r.owner = wd.owner - AND r.repo = wd.repo - '; - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'webhook_deliveries_repository_id_fkey' - ) THEN - ALTER TABLE webhook_deliveries - ADD CONSTRAINT webhook_deliveries_repository_id_fkey - FOREIGN KEY (repository_id) REFERENCES repositories(id); - END IF; - - DROP INDEX IF EXISTS webhook_deliveries_repo_idx; - CREATE INDEX IF NOT EXISTS webhook_deliveries_repo_idx ON webhook_deliveries (repository_id, received_at DESC); - - ALTER TABLE webhook_deliveries DROP COLUMN IF EXISTS owner; - ALTER TABLE webhook_deliveries DROP COLUMN IF EXISTS repo; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'file_reviews' - AND column_name = 'parsed_comments' - ) THEN - INSERT INTO review_comments ( - file_review_id, - path, - line, - position, - severity, - category, - title, - body, - code_suggestion - ) - SELECT - fr.id, - COALESCE(comment->>'path', fr.file_path), - NULLIF(comment->>'line', '')::int, - NULLIF(comment->>'position', '')::int, - COALESCE(comment->>'severity', 'P3'), - COALESCE(comment->>'category', 'quality'), - COALESCE(comment->>'title', 'Code finding'), - COALESCE(comment->>'body', ''), - comment->>'codeSuggestion' - FROM file_reviews fr - CROSS JOIN LATERAL jsonb_array_elements(COALESCE(fr.parsed_comments, '[]'::jsonb)) AS comment - WHERE NOT EXISTS ( - SELECT 1 FROM review_comments rc WHERE rc.file_review_id = fr.id - ); - - ALTER TABLE file_reviews DROP COLUMN parsed_comments; - END IF; -END $$; + +CREATE TABLE IF NOT EXISTS repositories ( + installation_id BIGINT NOT NULL, + id SERIAL PRIMARY KEY, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + UNIQUE(owner, repo) +); + +CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); + +CREATE TABLE IF NOT EXISTS review_comments ( + file_review_id UUID NOT NULL REFERENCES file_reviews(id) ON DELETE CASCADE, + id BIGSERIAL PRIMARY KEY, + line INTEGER, + position INTEGER, + path TEXT NOT NULL, + severity TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'quality', + title TEXT NOT NULL, + body TEXT COMPRESSION lz4 NOT NULL, + code_suggestion TEXT COMPRESSION lz4 +); + +CREATE INDEX IF NOT EXISTS review_comments_file_idx ON review_comments(file_review_id); + +DO $$ +DECLARE + has_old_job_repo_columns BOOLEAN; + has_old_repo_config_columns BOOLEAN; + has_old_webhook_repo_columns BOOLEAN; + commit_sha_type TEXT; + base_sha_type TEXT; + null_repository_jobs INTEGER; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'jobs' + AND column_name IN ('installation_id', 'owner', 'repo') + GROUP BY table_name + HAVING COUNT(*) = 3 + ) INTO has_old_job_repo_columns; + + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'repo_configs' + AND column_name IN ('installation_id', 'owner', 'repo') + GROUP BY table_name + HAVING COUNT(*) = 3 + ) INTO has_old_repo_config_columns; + + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'webhook_deliveries' + AND column_name IN ('owner', 'repo') + GROUP BY table_name + HAVING COUNT(*) = 2 + ) INTO has_old_webhook_repo_columns; + + IF has_old_job_repo_columns THEN + EXECUTE ' + INSERT INTO repositories (installation_id, owner, repo) + SELECT DISTINCT + CASE WHEN installation_id ~ ''^[0-9]+$'' THEN installation_id::bigint ELSE 0 END, + owner, + repo + FROM jobs + WHERE installation_id IS NOT NULL + AND owner IS NOT NULL + AND repo IS NOT NULL + ON CONFLICT (owner, repo) DO UPDATE + SET installation_id = EXCLUDED.installation_id + '; + END IF; + + IF has_old_repo_config_columns THEN + EXECUTE ' + INSERT INTO repositories (installation_id, owner, repo) + SELECT DISTINCT + CASE WHEN installation_id ~ ''^[0-9]+$'' THEN installation_id::bigint ELSE 0 END, + owner, + repo + FROM repo_configs + WHERE installation_id IS NOT NULL + AND owner IS NOT NULL + AND repo IS NOT NULL + ON CONFLICT (owner, repo) DO UPDATE + SET installation_id = EXCLUDED.installation_id + '; + END IF; + + ALTER TABLE jobs ADD COLUMN IF NOT EXISTS repository_id INTEGER; + + IF has_old_job_repo_columns THEN + EXECUTE ' + UPDATE jobs j + SET repository_id = r.id + FROM repositories r + WHERE j.repository_id IS NULL + AND r.owner = j.owner + AND r.repo = j.repo + '; + END IF; + + SELECT data_type + INTO commit_sha_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'jobs' + AND column_name = 'commit_sha'; + + IF commit_sha_type IS NOT NULL AND commit_sha_type <> 'bytea' THEN + ALTER TABLE jobs ADD COLUMN IF NOT EXISTS commit_sha_bytea BYTEA; + EXECUTE ' + UPDATE jobs + SET commit_sha_bytea = CASE + WHEN commit_sha ~ ''^[0-9a-fA-F]+$'' AND length(commit_sha) % 2 = 0 THEN decode(commit_sha, ''hex'') + ELSE convert_to(commit_sha, ''UTF8'') + END + WHERE commit_sha_bytea IS NULL + '; + ALTER TABLE jobs DROP COLUMN commit_sha; + ALTER TABLE jobs RENAME COLUMN commit_sha_bytea TO commit_sha; + END IF; + + SELECT data_type + INTO base_sha_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'jobs' + AND column_name = 'base_sha'; + + IF base_sha_type IS NOT NULL AND base_sha_type <> 'bytea' THEN + ALTER TABLE jobs ADD COLUMN IF NOT EXISTS base_sha_bytea BYTEA; + EXECUTE ' + UPDATE jobs + SET base_sha_bytea = CASE + WHEN base_sha ~ ''^[0-9a-fA-F]+$'' AND length(base_sha) % 2 = 0 THEN decode(base_sha, ''hex'') + ELSE convert_to(base_sha, ''UTF8'') + END + WHERE base_sha_bytea IS NULL + '; + ALTER TABLE jobs DROP COLUMN base_sha; + ALTER TABLE jobs RENAME COLUMN base_sha_bytea TO base_sha; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'jobs_repository_id_fkey' + ) THEN + ALTER TABLE jobs + ADD CONSTRAINT jobs_repository_id_fkey + FOREIGN KEY (repository_id) REFERENCES repositories(id); + END IF; + + SELECT COUNT(*) INTO null_repository_jobs FROM jobs WHERE repository_id IS NULL; + IF null_repository_jobs = 0 THEN + ALTER TABLE jobs ALTER COLUMN repository_id SET NOT NULL; + END IF; + + DROP INDEX IF EXISTS jobs_repo_idx; + DROP INDEX IF EXISTS jobs_status_idx; + DROP INDEX IF EXISTS jobs_created_idx; + DROP INDEX IF EXISTS jobs_head_sha_idx; + + CREATE INDEX IF NOT EXISTS jobs_repo_idx ON jobs (repository_id, pr_number); + CREATE INDEX IF NOT EXISTS jobs_active_idx ON jobs (status) WHERE status IN ('queued', 'running'); + CREATE INDEX IF NOT EXISTS jobs_created_idx ON jobs USING brin (created_at); + CREATE INDEX IF NOT EXISTS jobs_head_sha_idx ON jobs (repository_id, pr_number, commit_sha, trigger); + + IF has_old_job_repo_columns THEN + ALTER TABLE jobs DROP COLUMN IF EXISTS installation_id; + ALTER TABLE jobs DROP COLUMN IF EXISTS owner; + ALTER TABLE jobs DROP COLUMN IF EXISTS repo; + END IF; +END $$; + +DO $$ +DECLARE + has_old_columns BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'repo_configs' + AND column_name IN ('installation_id', 'owner', 'repo') + GROUP BY table_name + HAVING COUNT(*) = 3 + ) INTO has_old_columns; + + ALTER TABLE repo_configs ADD COLUMN IF NOT EXISTS repository_id INTEGER; + + IF has_old_columns THEN + EXECUTE ' + UPDATE repo_configs rc + SET repository_id = r.id + FROM repositories r + WHERE rc.repository_id IS NULL + AND r.owner = rc.owner + AND r.repo = rc.repo + '; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'repo_configs_repository_id_fkey' + ) THEN + ALTER TABLE repo_configs + ADD CONSTRAINT repo_configs_repository_id_fkey + FOREIGN KEY (repository_id) REFERENCES repositories(id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'repo_configs_repository_id_key' + ) THEN + ALTER TABLE repo_configs ADD CONSTRAINT repo_configs_repository_id_key UNIQUE (repository_id); + END IF; + + ALTER TABLE repo_configs DROP CONSTRAINT IF EXISTS repo_configs_owner_repo_key; + ALTER TABLE repo_configs DROP COLUMN IF EXISTS installation_id; + ALTER TABLE repo_configs DROP COLUMN IF EXISTS owner; + ALTER TABLE repo_configs DROP COLUMN IF EXISTS repo; + ALTER TABLE repo_configs DROP COLUMN IF EXISTS raw_yaml; + ALTER TABLE repo_configs DROP COLUMN IF EXISTS config_missing; +END $$; + +DO $$ +DECLARE + has_old_columns BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'webhook_deliveries' + AND column_name IN ('owner', 'repo') + GROUP BY table_name + HAVING COUNT(*) = 2 + ) INTO has_old_columns; + + ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS repository_id INTEGER; + + IF has_old_columns THEN + EXECUTE ' + UPDATE webhook_deliveries wd + SET repository_id = r.id + FROM repositories r + WHERE wd.repository_id IS NULL + AND r.owner = wd.owner + AND r.repo = wd.repo + '; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'webhook_deliveries_repository_id_fkey' + ) THEN + ALTER TABLE webhook_deliveries + ADD CONSTRAINT webhook_deliveries_repository_id_fkey + FOREIGN KEY (repository_id) REFERENCES repositories(id); + END IF; + + DROP INDEX IF EXISTS webhook_deliveries_repo_idx; + CREATE INDEX IF NOT EXISTS webhook_deliveries_repo_idx ON webhook_deliveries (repository_id, received_at DESC); + + ALTER TABLE webhook_deliveries DROP COLUMN IF EXISTS owner; + ALTER TABLE webhook_deliveries DROP COLUMN IF EXISTS repo; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'file_reviews' + AND column_name = 'parsed_comments' + ) THEN + INSERT INTO review_comments ( + file_review_id, + path, + line, + position, + severity, + category, + title, + body, + code_suggestion + ) + SELECT + fr.id, + COALESCE(comment->>'path', fr.file_path), + NULLIF(comment->>'line', '')::int, + NULLIF(comment->>'position', '')::int, + COALESCE(comment->>'severity', 'P3'), + COALESCE(comment->>'category', 'quality'), + COALESCE(comment->>'title', 'Code finding'), + COALESCE(comment->>'body', ''), + comment->>'codeSuggestion' + FROM file_reviews fr + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(fr.parsed_comments, '[]'::jsonb)) AS comment + WHERE NOT EXISTS ( + SELECT 1 FROM review_comments rc WHERE rc.file_review_id = fr.id + ); + + ALTER TABLE file_reviews DROP COLUMN parsed_comments; + END IF; +END $$; + +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS check_run_completed_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_owner TEXT; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS recovery_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_queue_message_at TIMESTAMPTZ; +ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS transient_error_count INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS jobs_lease_expiry_idx + ON jobs (lease_expires_at) + WHERE status = 'running' AND lease_expires_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS jobs_terminal_check_idx + ON jobs (status, check_run_completed_at) + WHERE check_run_id IS NOT NULL AND check_run_completed_at IS NULL; + +CREATE INDEX IF NOT EXISTS jobs_unleased_running_idx + ON jobs (last_queue_message_at, heartbeat_at) + WHERE status = 'running' AND lease_expires_at IS NULL; + +DELETE FROM file_reviews fr +USING ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY job_id, file_path ORDER BY created_at ASC, id ASC) AS row_number + FROM file_reviews +) ranked +WHERE fr.id = ranked.id + AND ranked.row_number > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS file_reviews_job_file_path_key + ON file_reviews (job_id, file_path); + +CREATE TABLE IF NOT EXISTS llm_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + api_format TEXT NOT NULL CHECK (api_format IN ('openai', 'anthropic', 'gemini', 'cloudflare-workers-ai')), + base_url TEXT, + encrypted_api_key TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +UPDATE llm_providers +SET name = 'Cloudflare', updated_at = now() +WHERE name = 'Cloudflare Workers AI'; + +UPDATE llm_providers +SET name = 'Google', updated_at = now() +WHERE name = 'Google Gemini'; + +INSERT INTO llm_providers (name, api_format, base_url, enabled) +VALUES + ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE), + ('Google', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', FALSE), + ('OpenAI', 'openai', 'https://api.openai.com/v1', FALSE), + ('Anthropic', 'anthropic', 'https://api.anthropic.com/v1', FALSE), + ('OpenRouter', 'openai', 'https://openrouter.ai/api/v1', FALSE) +ON CONFLICT (name) DO UPDATE SET + api_format = EXCLUDED.api_format, + base_url = EXCLUDED.base_url, + updated_at = now(); + +ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS provider_id UUID; +ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS model_name TEXT; + +UPDATE model_configs mc +SET + provider_id = provider_record.id, + model_name = COALESCE(mc.model_name, mc.model_id) +FROM llm_providers provider_record +WHERE mc.provider_id IS NULL + AND ( + (mc.provider = 'cloudflare' AND provider_record.name = 'Cloudflare') + OR (mc.provider = 'gemini' AND provider_record.name = 'Google') + OR (mc.provider = 'google' AND provider_record.name = 'Google') + OR (mc.provider = 'openai' AND provider_record.name = 'OpenAI') + OR (mc.provider = 'anthropic' AND provider_record.name = 'Anthropic') + ); + +UPDATE model_configs +SET model_name = model_id +WHERE model_name IS NULL; + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) +SELECT '@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare', p.id, '@cf/moonshotai/kimi-k2.6', now() +FROM llm_providers p +WHERE p.name = 'Cloudflare' +ON CONFLICT (model_id) DO UPDATE SET + rpm = EXCLUDED.rpm, + tpm = EXCLUDED.tpm, + rpd = EXCLUDED.rpd, + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) +SELECT '@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now() +FROM llm_providers p +WHERE p.name = 'Cloudflare' +ON CONFLICT (model_id) DO UPDATE SET + rpm = EXCLUDED.rpm, + tpm = EXCLUDED.tpm, + rpd = EXCLUDED.rpd, + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) +SELECT 'gemma-4-31b-it', 15, 1000000, 1500, 'gemini', p.id, 'gemma-4-31b-it', now() +FROM llm_providers p +WHERE p.name = 'Google' +ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) +SELECT 'gemma-4-26b-a4b-it', 30, 1000000, 1500, 'gemini', p.id, 'gemma-4-26b-a4b-it', now() +FROM llm_providers p +WHERE p.name = 'Google' +ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now(); + +ALTER TABLE model_configs ALTER COLUMN provider_id SET NOT NULL; +ALTER TABLE model_configs ALTER COLUMN model_name SET NOT NULL; +ALTER TABLE model_configs ALTER COLUMN rpm DROP NOT NULL; +ALTER TABLE model_configs ALTER COLUMN tpm DROP NOT NULL; +ALTER TABLE model_configs ALTER COLUMN rpd DROP NOT NULL; + +UPDATE model_configs +SET rpm = NULL, tpm = NULL, rpd = NULL, updated_at = now() +WHERE rpm = 1 AND tpm = 1 AND rpd = 1; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'model_configs_provider_id_fkey' + ) THEN + ALTER TABLE model_configs + ADD CONSTRAINT model_configs_provider_id_fkey + FOREIGN KEY (provider_id) REFERENCES llm_providers(id); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS model_configs_provider_id_idx ON model_configs (provider_id); diff --git a/db/migrations/002_llm.sql b/db/migrations/002_llm.sql deleted file mode 100644 index 9921c5c..0000000 --- a/db/migrations/002_llm.sql +++ /dev/null @@ -1,144 +0,0 @@ -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS check_run_completed_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_owner TEXT; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS recovery_count INTEGER NOT NULL DEFAULT 0; -ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_queue_message_at TIMESTAMPTZ; -ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS transient_error_count INTEGER NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS jobs_lease_expiry_idx - ON jobs (lease_expires_at) - WHERE status = 'running' AND lease_expires_at IS NOT NULL; - -CREATE INDEX IF NOT EXISTS jobs_terminal_check_idx - ON jobs (status, check_run_completed_at) - WHERE check_run_id IS NOT NULL AND check_run_completed_at IS NULL; - -CREATE INDEX IF NOT EXISTS jobs_unleased_running_idx - ON jobs (last_queue_message_at, heartbeat_at) - WHERE status = 'running' AND lease_expires_at IS NULL; - -DELETE FROM file_reviews fr -USING ( - SELECT id, ROW_NUMBER() OVER (PARTITION BY job_id, file_path ORDER BY created_at ASC, id ASC) AS row_number - FROM file_reviews -) ranked -WHERE fr.id = ranked.id - AND ranked.row_number > 1; - -CREATE UNIQUE INDEX IF NOT EXISTS file_reviews_job_file_path_key - ON file_reviews (job_id, file_path); - -CREATE TABLE IF NOT EXISTS llm_providers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - api_format TEXT NOT NULL CHECK (api_format IN ('openai', 'anthropic', 'gemini', 'cloudflare-workers-ai')), - base_url TEXT, - encrypted_api_key TEXT, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -UPDATE llm_providers -SET name = 'Cloudflare', updated_at = now() -WHERE name = 'Cloudflare Workers AI'; - -UPDATE llm_providers -SET name = 'Google', updated_at = now() -WHERE name = 'Google Gemini'; - -INSERT INTO llm_providers (name, api_format, base_url, enabled) -VALUES - ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE), - ('Google', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', FALSE), - ('OpenAI', 'openai', 'https://api.openai.com/v1', FALSE), - ('Anthropic', 'anthropic', 'https://api.anthropic.com/v1', FALSE), - ('OpenRouter', 'openai', 'https://openrouter.ai/api/v1', FALSE) -ON CONFLICT (name) DO UPDATE SET - api_format = EXCLUDED.api_format, - base_url = EXCLUDED.base_url, - updated_at = now(); - -ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS provider_id UUID; -ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS model_name TEXT; - -UPDATE model_configs mc -SET - provider_id = provider_record.id, - model_name = COALESCE(mc.model_name, mc.model_id) -FROM llm_providers provider_record -WHERE mc.provider_id IS NULL - AND ( - (mc.provider = 'cloudflare' AND provider_record.name = 'Cloudflare') - OR (mc.provider = 'gemini' AND provider_record.name = 'Google') - OR (mc.provider = 'google' AND provider_record.name = 'Google') - OR (mc.provider = 'openai' AND provider_record.name = 'OpenAI') - OR (mc.provider = 'anthropic' AND provider_record.name = 'Anthropic') - ); - -UPDATE model_configs -SET model_name = model_id -WHERE model_name IS NULL; - -INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) -SELECT '@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare', p.id, '@cf/moonshotai/kimi-k2.6', now() -FROM llm_providers p -WHERE p.name = 'Cloudflare' -ON CONFLICT (model_id) DO UPDATE SET - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, - provider = EXCLUDED.provider, - provider_id = EXCLUDED.provider_id, - model_name = EXCLUDED.model_name, - updated_at = now(); - -INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) -SELECT '@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now() -FROM llm_providers p -WHERE p.name = 'Cloudflare' -ON CONFLICT (model_id) DO UPDATE SET - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, - provider = EXCLUDED.provider, - provider_id = EXCLUDED.provider_id, - model_name = EXCLUDED.model_name, - updated_at = now(); - -INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) -SELECT 'gemma-4-31b-it', 15, 1000000, 1500, 'gemini', p.id, 'gemma-4-31b-it', now() -FROM llm_providers p -WHERE p.name = 'Google' -ON CONFLICT (model_id) DO UPDATE SET - provider = EXCLUDED.provider, - provider_id = EXCLUDED.provider_id, - model_name = EXCLUDED.model_name, - updated_at = now(); - -INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) -SELECT 'gemma-4-26b-a4b-it', 30, 1000000, 1500, 'gemini', p.id, 'gemma-4-26b-a4b-it', now() -FROM llm_providers p -WHERE p.name = 'Google' -ON CONFLICT (model_id) DO UPDATE SET - provider = EXCLUDED.provider, - provider_id = EXCLUDED.provider_id, - model_name = EXCLUDED.model_name, - updated_at = now(); - -ALTER TABLE model_configs ALTER COLUMN provider_id SET NOT NULL; -ALTER TABLE model_configs ALTER COLUMN model_name SET NOT NULL; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'model_configs_provider_id_fkey' - ) THEN - ALTER TABLE model_configs - ADD CONSTRAINT model_configs_provider_id_fkey - FOREIGN KEY (provider_id) REFERENCES llm_providers(id); - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS model_configs_provider_id_idx ON model_configs (provider_id); diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index b3e7214..3cf65bd 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -229,6 +229,15 @@ async function ensureModelCatalog() { return; } + await query('ALTER TABLE model_configs ALTER COLUMN rpm DROP NOT NULL'); + await query('ALTER TABLE model_configs ALTER COLUMN tpm DROP NOT NULL'); + await query('ALTER TABLE model_configs ALTER COLUMN rpd DROP NOT NULL'); + await query(` + UPDATE model_configs + SET rpm = NULL, tpm = NULL, rpd = NULL, updated_at = now() + WHERE rpm = 1 AND tpm = 1 AND rpd = 1 + `); + await query( ` INSERT INTO llm_providers (name, api_format, base_url, enabled) diff --git a/src/client/components/features/models/model-chain.tsx b/src/client/components/features/models/model-chain.tsx index cb27b60..4775737 100644 --- a/src/client/components/features/models/model-chain.tsx +++ b/src/client/components/features/models/model-chain.tsx @@ -24,7 +24,7 @@ export type ModelRouteTier = { }; export type ModelRouteConfig = { - main: string; + main: string | null; fallbacks: string[]; size_overrides: ModelRouteTier[]; }; @@ -38,17 +38,21 @@ export function getModelLabel(model: string, models: ModelOption[] = []) { } export function describeModelRoute(config: ModelRouteConfig, models: ModelOption[] = []) { + if (!config.main && (config.fallbacks?.length ?? 0) === 0 && (config.size_overrides?.length ?? 0) === 0) { + return 'No model strategy configured'; + } + const fallbacks = config.fallbacks?.length ?? 0; const tiers = config.size_overrides?.length ?? 0; return [ - getModelLabel(config.main, models), + config.main ? getModelLabel(config.main, models) : 'No baseline model', fallbacks > 0 ? `${fallbacks} fallback${fallbacks === 1 ? '' : 's'}` : 'no fallbacks', tiers > 0 ? `${tiers} tier${tiers === 1 ? '' : 's'}` : 'baseline only', ].join(' · '); } interface ModelSelectorProps { - value: string; + value: string | null; onValueChange: (value: string) => void; models: ModelOption[]; providers: ProviderOption[]; @@ -66,7 +70,7 @@ export function ModelSelector({ density = 'comfortable', className, }: ModelSelectorProps) { - const currentModel = models.find(m => m.value === value) || models[0]; + const currentModel = models.find(m => m.value === value); const [provider, setProvider] = useState(currentModel?.providerId ?? providers[0]?.value ?? ''); useEffect(() => { @@ -112,9 +116,10 @@ export function ModelSelector({ /> setNewModel(current => ({ ...current, [field]: Number(e.target.value) || 1 }))} + value={newModel[field] ?? ''} + placeholder="None" + onChange={e => setNewModel(current => ({ ...current, [field]: parseOptionalLimit(e.target.value) }))} /> ))} @@ -1111,7 +1111,7 @@ export function SettingsPage() {
{(['rpm', 'rpd', 'tpm'] as const).map(field => ( - {field.toUpperCase()} {cfg[field].toLocaleString()} + {field.toUpperCase()} {formatOptionalLimit(cfg[field])} ))}
@@ -1185,8 +1185,9 @@ export function SettingsPage() { updateQuota(cfg.modelId, field, Number(e.target.value) || 0)} + value={cfg[field] ?? ''} + placeholder="None" + onChange={e => updateQuota(cfg.modelId, field, parseOptionalLimit(e.target.value))} /> ))} diff --git a/src/server/core/config.ts b/src/server/core/config.ts index 8ecc766..0872cb9 100644 --- a/src/server/core/config.ts +++ b/src/server/core/config.ts @@ -22,21 +22,10 @@ async function cacheKey(env: Pick, owner: string, repo: s const GLOBAL_CONFIG_KEY = 'config:global_model'; -const SERVER_DEFAULT_GLOBAL_CONFIG: RepoConfig['model'] = { - main: 'gemma-4-31b-it', - fallbacks: ['gemma-4-26b-a4b-it', '@cf/zai-org/glm-4.7-flash'], - size_overrides: [ - { - max_lines: 300, - model: 'gemma-4-31b-it', - fallbacks: ['gemma-4-26b-a4b-it', '@cf/zai-org/glm-4.7-flash'], - }, - { - max_lines: 100, - model: '@cf/moonshotai/kimi-k2.6', - fallbacks: ['@cf/zai-org/glm-4.7-flash'], - }, - ], +const EMPTY_GLOBAL_CONFIG: RepoConfig['model'] = { + main: null, + fallbacks: [], + size_overrides: [], }; function hasRepoModelOverride(existing: Awaited> | null) { @@ -51,7 +40,7 @@ export async function getGlobalConfig(env: Pick): Promise const cached = await env.APP_KV.get(GLOBAL_CONFIG_KEY, 'json'); if (cached) return normalizeRepoModelConfig(cached as RepoConfig['model']); - return SERVER_DEFAULT_GLOBAL_CONFIG; + return EMPTY_GLOBAL_CONFIG; } export async function updateGlobalConfig(env: Pick, config: RepoConfig['model']) { diff --git a/src/server/core/review.ts b/src/server/core/review.ts index d55f275..26b4c58 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -74,7 +74,7 @@ function configuredModelSet(config: RepoConfig) { if (model) models.add(normalizeModelId(model)); }; - addModel(config.model?.main ?? 'gemma-4-31b-it'); + addModel(config.model?.main); for (const fallback of config.model?.fallbacks ?? []) { addModel(fallback); } @@ -561,7 +561,7 @@ async function reviewAndPersistFile( const errorMessage = error instanceof Error ? error.message : 'Unknown file review error'; if (isRetryableModelError(error)) { - const modelId = config.model?.main ?? 'gemma-4-31b-it'; + const modelId = config.model?.main ?? 'unconfigured'; const failureCount = await recordRetryableFileReviewFailure(env, job.id, { filePath: file.path, modelUsed: modelId, @@ -619,7 +619,7 @@ async function reviewAndPersistFile( throw error; } - const modelId = config.model?.main ?? 'gemma-4-31b-it'; + const modelId = config.model?.main ?? 'unconfigured'; await upsertFileReview(env, job.id, { filePath: file.path, fileStatus: 'failed', diff --git a/src/server/db/model-configs.ts b/src/server/db/model-configs.ts index f15979a..8a4da92 100644 --- a/src/server/db/model-configs.ts +++ b/src/server/db/model-configs.ts @@ -26,9 +26,9 @@ type ModelConfigRow = { provider_name: string; api_format: LlmApiFormat; model_name: string; - rpm: number; - tpm: number; - rpd: number; + rpm: number | null; + tpm: number | null; + rpd: number | null; updated_at: string; }; @@ -351,9 +351,9 @@ export async function upsertDiscoveredModelConfigs( model_id: string; provider_id: string; model_name: string; - rpm: number; - tpm: number; - rpd: number; + rpm: number | null; + tpm: number | null; + rpd: number | null; provider: LlmApiFormat; }> = []; @@ -373,9 +373,9 @@ export async function upsertDiscoveredModelConfigs( model_id: candidate, provider_id: input.providerId, model_name: modelName, - rpm: 60, - tpm: 1_000_000, - rpd: 1_000, + rpm: null, + tpm: null, + rpd: null, provider: input.apiFormat, }); } diff --git a/src/server/routes/api/models.ts b/src/server/routes/api/models.ts index fe62104..307d504 100644 --- a/src/server/routes/api/models.ts +++ b/src/server/routes/api/models.ts @@ -28,6 +28,7 @@ import { ProviderRequestError } from '@server/models/types'; const apiFormatSchema = z.enum(llmApiFormats); const positiveIntegerSchema = z.number().int().positive().finite(); +const optionalLimitSchema = positiveIntegerSchema.nullable(); const modelIdSchema = z.string().trim().min(1); const optionalUrlSchema = z.string().trim().url().nullable().optional(); const providerIdSchema = z.string().uuid(); @@ -47,13 +48,13 @@ const providerUpdateSchema = providerCreateSchema.extend({ const modelConfigUpdateSchema = z.object({ providerId: providerIdSchema, modelName: z.string().trim().min(1), - rpm: positiveIntegerSchema, - tpm: positiveIntegerSchema, - rpd: positiveIntegerSchema, + rpm: optionalLimitSchema, + tpm: optionalLimitSchema, + rpd: optionalLimitSchema, }).strict(); const globalModelConfigSchema = z.object({ - main: modelIdSchema.nullable().default('gemma-4-31b-it'), + main: modelIdSchema.nullable().default(null), fallbacks: z.array(modelIdSchema).nullable().default([]), size_overrides: z .array( diff --git a/src/server/services/model.ts b/src/server/services/model.ts index 6cb36c9..144a9a2 100644 --- a/src/server/services/model.ts +++ b/src/server/services/model.ts @@ -135,19 +135,11 @@ export class ModelService { const { model: modelCfg } = params.config; const thresholdBase = params.totalLineCount; - // Use default if not configured - if (!modelCfg) { - return { - primary: 'gemma-4-31b-it', - fallbacks: ['gemma-4-26b-a4b-it', '@cf/zai-org/glm-4.7-flash'] - }; - } - - let selectedModel = normalizeModel(modelCfg.main ?? 'gemma-4-31b-it'); - let fallbackModels = (modelCfg.fallbacks || []).map(normalizeModel); + let selectedModel = modelCfg?.main ? normalizeModel(modelCfg.main) : null; + let fallbackModels = (modelCfg?.fallbacks || []).map(normalizeModel); // Apply size overrides based on total PR lines - if (modelCfg.size_overrides && modelCfg.size_overrides.length > 0) { + if (modelCfg?.size_overrides && modelCfg.size_overrides.length > 0) { const sortedOverrides = [...modelCfg.size_overrides].sort((a, b) => a.max_lines - b.max_lines); const matched = sortedOverrides.find(o => thresholdBase <= o.max_lines); if (matched) { @@ -156,8 +148,12 @@ export class ModelService { } } - const chain = uniqueModels([selectedModel, ...fallbackModels]); - selectedModel = chain[0] ?? 'gemma-4-31b-it'; + const chain = uniqueModels([...(selectedModel ? [selectedModel] : []), ...fallbackModels]); + if (chain.length === 0) { + throw new Error('No review model strategy is configured. Choose a global model strategy in Settings, or configure this repository.'); + } + + selectedModel = chain[0]; fallbackModels = chain.slice(1); return { primary: selectedModel, fallbacks: fallbackModels }; diff --git a/src/shared/schema.ts b/src/shared/schema.ts index e88bb4e..4cdc2fc 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -123,7 +123,7 @@ export const repoConfigSchema = z.object({ }), model: z .object({ - main: z.string().nullable().default('gemma-4-31b-it'), + main: z.string().nullable().default(null), fallbacks: z.array(z.string()).nullable().default([]), size_overrides: z .array( @@ -137,8 +137,8 @@ export const repoConfigSchema = z.object({ .optional(), }) .default({ - main: 'gemma-4-31b-it', - fallbacks: ['gemma-4-26b-a4b-it', '@cf/zai-org/glm-4.7-flash'], + main: null, + fallbacks: [], size_overrides: [], }), }); @@ -315,8 +315,12 @@ export function normalizeModelId(model: string) { export function normalizeRepoModelConfig(model: RepoConfig['model']): RepoConfig['model'] { return { ...model, - main: model.main === null ? null : normalizeModelId(model.main), - fallbacks: model.fallbacks === null ? null : model.fallbacks.map(normalizeModelId), + main: model.main ? normalizeModelId(model.main) : null, + fallbacks: model.fallbacks === null + ? null + : Array.isArray(model.fallbacks) + ? model.fallbacks.map(normalizeModelId) + : [], size_overrides: model.size_overrides === null || model.size_overrides === undefined ? model.size_overrides : model.size_overrides.map((tier) => ({ @@ -356,9 +360,9 @@ export const modelConfigSchema = z.object({ providerName: z.string(), apiFormat: z.enum(llmApiFormats), modelName: z.string(), - rpm: z.number().int(), - tpm: z.number().int(), - rpd: z.number().int(), + rpm: z.number().int().nullable(), + tpm: z.number().int().nullable(), + rpd: z.number().int().nullable(), updatedAt: dateStringSchema, }); diff --git a/test/api.spec.ts b/test/api.spec.ts index eda857e..27e3d15 100644 --- a/test/api.spec.ts +++ b/test/api.spec.ts @@ -428,7 +428,7 @@ describe('Dashboard API Suite', () => { 'content-type': 'application/json', }, body: JSON.stringify({ - name: 'Disabled No Key Provider', + name: `Disabled No Key Provider ${Date.now()}`, apiFormat: 'openai', baseUrl: 'https://api.example.com/v1', enabled: false, @@ -458,6 +458,7 @@ describe('Dashboard API Suite', () => { const env = createTestEnv(); const token = await getAuthCookie(env); await saveTestProviderApiKey(env); + const discoveredModelName = `test-discovered-${Date.now()}`; vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const url = String(input); if (url.includes('/ai/models/search')) { @@ -471,7 +472,7 @@ describe('Dashboard API Suite', () => { return Response.json({ models: [ { - name: 'models/gemini-2.5-flash', + name: `models/${discoveredModelName}`, supportedGenerationMethods: ['generateContent'], }, ], @@ -489,7 +490,8 @@ describe('Dashboard API Suite', () => { expect(response.status).toBe(200); const data = await response.json() as ModelConfigsResponse; - expect(data.configs.some(config => config.modelName === 'gemini-2.5-flash')).toBe(true); + const discoveredGoogleModel = data.configs.find(config => config.modelName === discoveredModelName); + expect(discoveredGoogleModel).toMatchObject({ rpm: null, rpd: null, tpm: null }); expect(data.configs.some(config => config.providerName === 'Cloudflare' && config.modelName === '@cf/openai/gpt-oss-120b')).toBe(true); expect(data.syncErrors).toEqual([]); }); diff --git a/test/model-service.spec.ts b/test/model-service.spec.ts index e134e2c..73a414f 100644 --- a/test/model-service.spec.ts +++ b/test/model-service.spec.ts @@ -51,6 +51,15 @@ describe('ModelService', () => { }); }); + it('fails clearly when no model strategy is configured', () => { + const service = new ModelService(createTestEnv()); + + expect(() => (service as any).selectModel({ + totalLineCount: 1, + config: defaultRepoConfig, + })).toThrow('No review model strategy is configured'); + }); + it('turns Cloudflare reasoning-only responses into inconclusive review JSON', async () => { const env = createTestEnv({ AI: { diff --git a/test/settings.spec.ts b/test/settings.spec.ts index fbbe084..cbea8a4 100644 --- a/test/settings.spec.ts +++ b/test/settings.spec.ts @@ -2,6 +2,14 @@ import { describe, expect, it } from 'vitest'; import { normalizeGlobalConfig } from '@client/pages/settings'; describe('settings model strategy', () => { + it('does not invent a global strategy when none has been saved', () => { + expect(normalizeGlobalConfig(null)).toEqual({ + main: null, + fallbacks: [], + size_overrides: [], + }); + }); + it('preserves an explicit empty global fallback list', () => { const config = normalizeGlobalConfig({ main: 'gemma-4-31b-it', From 5eb8c1a74fec4dcf6d3509a187d2751c1b3e3a0c Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 26 May 2026 20:28:08 +0530 Subject: [PATCH 4/6] fix: db migration assistant on existing dbs --- db/migrations/001_initial.sql | 27 ++---- scripts/migrate.mjs | 154 +++++++++++++++++++++++++++++++--- 2 files changed, 151 insertions(+), 30 deletions(-) diff --git a/db/migrations/001_initial.sql b/db/migrations/001_initial.sql index a3232de..2a8a98d 100644 --- a/db/migrations/001_initial.sql +++ b/db/migrations/001_initial.sql @@ -159,22 +159,9 @@ CREATE TABLE IF NOT EXISTS model_configs ( provider TEXT NOT NULL ); -INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider) -VALUES - ('gemma-4-31b-it', 15, 1000000, 1500, 'google'), - ('gemma-4-26b-a4b-it', 30, 1000000, 1500, 'google'), - ('@cf/moonshotai/kimi-k2.6', 10, 131072, 300, 'cloudflare'), - ('@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare') -ON CONFLICT (model_id) DO UPDATE SET - rpm = EXCLUDED.rpm, - tpm = EXCLUDED.tpm, - rpd = EXCLUDED.rpd, - provider = EXCLUDED.provider, - updated_at = now(); - DELETE FROM model_configs WHERE model_id = '@cf/moonshotai/kimi-k2.5'; -CREATE OR REPLACE FUNCTION pg_temp.replace_deprecated_model(input jsonb, old_value text, new_value text) +CREATE OR REPLACE FUNCTION public.codra_replace_deprecated_model(input jsonb, old_value text, new_value text) RETURNS jsonb LANGUAGE sql IMMUTABLE @@ -183,14 +170,14 @@ AS $$ WHEN 'string' THEN CASE WHEN input #>> '{}' = old_value THEN to_jsonb(new_value) ELSE input END WHEN 'array' THEN COALESCE( ( - SELECT jsonb_agg(pg_temp.replace_deprecated_model(value, old_value, new_value) ORDER BY ord) + SELECT jsonb_agg(public.codra_replace_deprecated_model(value, old_value, new_value) ORDER BY ord) FROM jsonb_array_elements(input) WITH ORDINALITY AS item(value, ord) ), '[]'::jsonb ) WHEN 'object' THEN COALESCE( ( - SELECT jsonb_object_agg(key, pg_temp.replace_deprecated_model(value, old_value, new_value)) + SELECT jsonb_object_agg(key, public.codra_replace_deprecated_model(value, old_value, new_value)) FROM jsonb_each(input) ), '{}'::jsonb @@ -204,21 +191,23 @@ SET main_model = CASE WHEN main_model = '@cf/moonshotai/kimi-k2.5' THEN '@cf/moonshotai/kimi-k2.6' ELSE main_model END, fallback_models = CASE WHEN fallback_models IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(fallback_models, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') + ELSE public.codra_replace_deprecated_model(fallback_models, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') END, size_overrides = CASE WHEN size_overrides IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(size_overrides, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') + ELSE public.codra_replace_deprecated_model(size_overrides, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') END, parsed_json = CASE WHEN parsed_json IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(parsed_json, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') + ELSE public.codra_replace_deprecated_model(parsed_json, '@cf/moonshotai/kimi-k2.5', '@cf/moonshotai/kimi-k2.6') END WHERE main_model = '@cf/moonshotai/kimi-k2.5' OR fallback_models::text LIKE '%@cf/moonshotai/kimi-k2.5%' OR size_overrides::text LIKE '%@cf/moonshotai/kimi-k2.5%' OR parsed_json::text LIKE '%@cf/moonshotai/kimi-k2.5%'; +DROP FUNCTION IF EXISTS public.codra_replace_deprecated_model(jsonb, text, text); + CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS repositories ( diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 3cf65bd..98291cb 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -229,6 +229,48 @@ async function ensureModelCatalog() { return; } + await query(` + CREATE TABLE IF NOT EXISTS llm_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + api_format TEXT NOT NULL CHECK (api_format IN ('openai', 'anthropic', 'gemini', 'cloudflare-workers-ai')), + base_url TEXT, + encrypted_api_key TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + + await query(` + UPDATE llm_providers + SET name = 'Cloudflare', updated_at = now() + WHERE name = 'Cloudflare Workers AI' + `); + + await query(` + UPDATE llm_providers + SET name = 'Google', updated_at = now() + WHERE name = 'Google Gemini' + `); + + await query(` + INSERT INTO llm_providers (name, api_format, base_url, enabled) + VALUES + ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE), + ('Google', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', FALSE), + ('OpenAI', 'openai', 'https://api.openai.com/v1', FALSE), + ('Anthropic', 'anthropic', 'https://api.anthropic.com/v1', FALSE), + ('OpenRouter', 'openai', 'https://openrouter.ai/api/v1', FALSE) + ON CONFLICT (name) DO UPDATE SET + api_format = EXCLUDED.api_format, + base_url = EXCLUDED.base_url, + updated_at = now() + `); + + await query('ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS provider_id UUID'); + await query('ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS model_name TEXT'); + await query('ALTER TABLE model_configs ALTER COLUMN rpm DROP NOT NULL'); await query('ALTER TABLE model_configs ALTER COLUMN tpm DROP NOT NULL'); await query('ALTER TABLE model_configs ALTER COLUMN rpd DROP NOT NULL'); @@ -248,16 +290,104 @@ async function ensureModelCatalog() { await query( ` - INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name) - SELECT $1, 10, 131072, 300, 'cloudflare', id, $1 - FROM llm_providers - WHERE name = 'Cloudflare' - ON CONFLICT (model_id) DO NOTHING + UPDATE model_configs mc + SET + provider_id = provider_record.id, + model_name = COALESCE(mc.model_name, mc.model_id) + FROM llm_providers provider_record + WHERE mc.provider_id IS NULL + AND ( + (mc.provider = 'cloudflare' AND provider_record.name = 'Cloudflare') + OR (mc.provider = 'gemini' AND provider_record.name = 'Google') + OR (mc.provider = 'google' AND provider_record.name = 'Google') + OR (mc.provider = 'openai' AND provider_record.name = 'OpenAI') + OR (mc.provider = 'anthropic' AND provider_record.name = 'Anthropic') + ) + `, + ); + + await query('UPDATE model_configs SET model_name = model_id WHERE model_name IS NULL'); + + await query( + ` + INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) + SELECT $1, 10, 131072, 300, 'cloudflare', p.id, $1, now() + FROM llm_providers p + WHERE p.name = 'Cloudflare' + ON CONFLICT (model_id) DO UPDATE SET + rpm = EXCLUDED.rpm, + tpm = EXCLUDED.tpm, + rpd = EXCLUDED.rpd, + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now() `, [kimiK26Model], ); + await query( + ` + INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) + SELECT '@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now() + FROM llm_providers p + WHERE p.name = 'Cloudflare' + ON CONFLICT (model_id) DO UPDATE SET + rpm = EXCLUDED.rpm, + tpm = EXCLUDED.tpm, + rpd = EXCLUDED.rpd, + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now() + `, + ); + + await query( + ` + INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) + SELECT 'gemma-4-31b-it', 15, 1000000, 1500, 'gemini', p.id, 'gemma-4-31b-it', now() + FROM llm_providers p + WHERE p.name = 'Google' + ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now() + `, + ); + + await query( + ` + INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at) + SELECT 'gemma-4-26b-a4b-it', 30, 1000000, 1500, 'gemini', p.id, 'gemma-4-26b-a4b-it', now() + FROM llm_providers p + WHERE p.name = 'Google' + ON CONFLICT (model_id) DO UPDATE SET + provider = EXCLUDED.provider, + provider_id = EXCLUDED.provider_id, + model_name = EXCLUDED.model_name, + updated_at = now() + `, + ); + await query('DELETE FROM model_configs WHERE model_id = $1', [kimiK25Model]); + + await query('ALTER TABLE model_configs ALTER COLUMN provider_id SET NOT NULL'); + await query('ALTER TABLE model_configs ALTER COLUMN model_name SET NOT NULL'); + await query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'model_configs_provider_id_fkey' + ) THEN + ALTER TABLE model_configs + ADD CONSTRAINT model_configs_provider_id_fkey + FOREIGN KEY (provider_id) REFERENCES llm_providers(id); + END IF; + END $$ + `); + await query('CREATE INDEX IF NOT EXISTS model_configs_provider_id_idx ON model_configs (provider_id)'); } async function normalizeRepoConfigs() { @@ -266,7 +396,7 @@ async function normalizeRepoConfigs() { } await query(` - CREATE OR REPLACE FUNCTION pg_temp.replace_deprecated_model(input jsonb, old_value text, new_value text) + CREATE OR REPLACE FUNCTION public.codra_replace_deprecated_model(input jsonb, old_value text, new_value text) RETURNS jsonb LANGUAGE sql IMMUTABLE @@ -275,14 +405,14 @@ async function normalizeRepoConfigs() { WHEN 'string' THEN CASE WHEN input #>> '{}' = old_value THEN to_jsonb(new_value) ELSE input END WHEN 'array' THEN COALESCE( ( - SELECT jsonb_agg(pg_temp.replace_deprecated_model(value, old_value, new_value) ORDER BY ord) + SELECT jsonb_agg(public.codra_replace_deprecated_model(value, old_value, new_value) ORDER BY ord) FROM jsonb_array_elements(input) WITH ORDINALITY AS item(value, ord) ), '[]'::jsonb ) WHEN 'object' THEN COALESCE( ( - SELECT jsonb_object_agg(key, pg_temp.replace_deprecated_model(value, old_value, new_value)) + SELECT jsonb_object_agg(key, public.codra_replace_deprecated_model(value, old_value, new_value)) FROM jsonb_each(input) ), '{}'::jsonb @@ -299,15 +429,15 @@ async function normalizeRepoConfigs() { main_model = CASE WHEN main_model = $1 THEN $2 ELSE main_model END, fallback_models = CASE WHEN fallback_models IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(fallback_models, $1, $2) + ELSE public.codra_replace_deprecated_model(fallback_models, $1, $2) END, size_overrides = CASE WHEN size_overrides IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(size_overrides, $1, $2) + ELSE public.codra_replace_deprecated_model(size_overrides, $1, $2) END, parsed_json = CASE WHEN parsed_json IS NULL THEN NULL - ELSE pg_temp.replace_deprecated_model(parsed_json, $1, $2) + ELSE public.codra_replace_deprecated_model(parsed_json, $1, $2) END WHERE main_model = $1 OR fallback_models::text LIKE '%' || $1 || '%' @@ -316,6 +446,8 @@ async function normalizeRepoConfigs() { `, [kimiK25Model, kimiK26Model], ); + + await query('DROP FUNCTION IF EXISTS public.codra_replace_deprecated_model(jsonb, text, text)'); } async function main() { From 04372bc3a2cfaf3b2a6fdb190448c2a158ea2579 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 26 May 2026 21:13:44 +0530 Subject: [PATCH 5/6] add: consolidate LLM provider management and make rate limits optional --- src/client/lib/api.ts | 39 +++++++++++++++++++++++---------- src/client/pages/repos.tsx | 30 +++++++++++++++---------- src/server/core/review.ts | 23 ++++++++++++++++--- src/server/db/model-configs.ts | 5 +++-- src/server/models/cloudflare.ts | 8 ++++++- 5 files changed, 76 insertions(+), 29 deletions(-) diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 475e817..7148d52 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -11,13 +11,30 @@ import type { SyncReposResponse, UpdatesEmailResponse, } from '@shared/api'; +import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig } from '@shared/schema'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); function pathSegment(value: string) { - return encodeURIComponent(value); + const trimmed = value.trim(); + if (!trimmed) { + throw new Error('Path segment cannot be empty.'); + } + return encodeURIComponent(trimmed); } +type QueryValue = string | number | boolean | null | undefined; +type ModelConfigPayload = Pick; +type ProviderPayload = { + name: string; + apiFormat: LlmApiFormat; + baseUrl: string | null; + apiKey?: string; + clearApiKey?: boolean; + enabled: boolean; +}; +type RepoConfigPatch = Partial & { enabled: boolean }>; + async function request(input: string, init?: RequestInit) { const method = init?.method?.toUpperCase() ?? 'GET'; const headers = new Headers(init?.headers); @@ -119,7 +136,7 @@ export const api = { body: JSON.stringify({ email }), }); }, - getJobs(params: Record = {}) { + getJobs(params: Record = {}) { const searchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== '') { @@ -171,7 +188,7 @@ export const api = { body: JSON.stringify({ lease_ids: leaseIds }), }); }, - updateRepoConfig(owner: string, repo: string, config: any) { + updateRepoConfig(owner: string, repo: string, config: RepoConfigPatch) { return request<{ ok: boolean }>(`/api/repos/${owner}/${repo}/config`, { method: 'PATCH', body: JSON.stringify(config), @@ -185,8 +202,8 @@ export const api = { method: 'POST', }); }, - updateModelConfig(id: string, config: any) { - return request<{ ok: boolean; config: import('@shared/schema').ModelConfig }>(`/api/models/${pathSegment(id)}`, { + updateModelConfig(id: string, config: ModelConfigPayload) { + return request<{ ok: boolean; config: ModelConfig }>(`/api/models/${pathSegment(id)}`, { method: 'POST', body: JSON.stringify(config), }); @@ -196,14 +213,14 @@ export const api = { method: 'DELETE', }); }, - createProvider(config: any) { - return request<{ provider: any }>('/api/models/providers', { + createProvider(config: ProviderPayload) { + return request<{ provider: LlmProvider }>('/api/models/providers', { method: 'POST', body: JSON.stringify(config), }); }, - updateProvider(id: string, config: any) { - return request<{ provider: any }>(`/api/models/providers/${pathSegment(id)}`, { + updateProvider(id: string, config: ProviderPayload) { + return request<{ provider: LlmProvider }>(`/api/models/providers/${pathSegment(id)}`, { method: 'PATCH', body: JSON.stringify(config), }); @@ -219,9 +236,9 @@ export const api = { }); }, getGlobalConfig() { - return request<{ config: any }>('/api/models/global'); + return request<{ config: RepoConfig['model'] }>('/api/models/global'); }, - updateGlobalConfig(config: any) { + updateGlobalConfig(config: RepoConfig['model']) { return request<{ ok: boolean }>('/api/models/global', { method: 'PATCH', body: JSON.stringify(config), diff --git a/src/client/pages/repos.tsx b/src/client/pages/repos.tsx index 3c2f712..8b599ee 100644 --- a/src/client/pages/repos.tsx +++ b/src/client/pages/repos.tsx @@ -18,7 +18,7 @@ import { X, } from 'lucide-react'; import { cn } from '@client/lib/utils'; -import type { RepoConfigRecord } from '@shared/schema'; +import type { RepoConfig, RepoConfigRecord } from '@shared/schema'; import { describeModelRoute, ModelRouteEditor, @@ -33,6 +33,8 @@ const EMPTY_MODEL_ROUTE: ModelRouteConfig = { size_overrides: [], }; +type GlobalModelConfig = RepoConfig['model']; + function repoId(repo: Pick) { return `${repo.owner}/${repo.repo}`; } @@ -41,7 +43,7 @@ function hasStoredModelStrategy(repo: RepoConfigRecord) { return repo.mainModel !== null || repo.fallbackModels !== null || repo.sizeOverrides !== null; } -function normalizeRoute(config: any): ModelRouteConfig { +function normalizeRoute(config: GlobalModelConfig | ModelRouteConfig | null | undefined): ModelRouteConfig { return { main: typeof config?.main === 'string' && config.main.trim() ? config.main : null, fallbacks: Array.isArray(config?.fallbacks) ? config.fallbacks : EMPTY_MODEL_ROUTE.fallbacks, @@ -51,7 +53,7 @@ function normalizeRoute(config: any): ModelRouteConfig { }; } -function getGlobalRoute(globalConfig: any): ModelRouteConfig { +function getGlobalRoute(globalConfig: GlobalModelConfig | ModelRouteConfig | null): ModelRouteConfig { return normalizeRoute(globalConfig); } @@ -69,7 +71,7 @@ function routesEqual(a: ModelRouteConfig, b: ModelRouteConfig) { return JSON.stringify(a) === JSON.stringify(b); } -function hasMeaningfulCustomStrategy(repo: RepoConfigRecord, globalConfig: any) { +function hasMeaningfulCustomStrategy(repo: RepoConfigRecord, globalConfig: GlobalModelConfig | ModelRouteConfig | null) { const storedRoute = getStoredRepoRoute(repo); if (!storedRoute) return false; @@ -79,7 +81,7 @@ function hasMeaningfulCustomStrategy(repo: RepoConfigRecord, globalConfig: any) ); } -function getRepoRoute(repo: RepoConfigRecord, globalConfig: any): ModelRouteConfig { +function getRepoRoute(repo: RepoConfigRecord, globalConfig: GlobalModelConfig | ModelRouteConfig | null): ModelRouteConfig { if (!hasMeaningfulCustomStrategy(repo, globalConfig)) { return getGlobalRoute(globalConfig); } @@ -94,7 +96,7 @@ function formatLastActivity(value: string | Date | null) { interface RepoRowProps { repo: RepoConfigRecord; - globalConfig: any; + globalConfig: GlobalModelConfig | ModelRouteConfig | null; modelOptions: ModelOption[]; togglePending: boolean; onToggleEnabled: (repo: RepoConfigRecord, enabled: boolean) => void; @@ -190,7 +192,7 @@ function RepoRow({ interface RepoModelModalProps { repo: RepoConfigRecord | null; - globalConfig: any; + globalConfig: GlobalModelConfig | ModelRouteConfig | null; modelOptions: ModelOption[]; providerOptions: ProviderOption[]; open: boolean; @@ -343,7 +345,7 @@ function RepoModelModal({ export function ReposPage() { const [repos, setRepos] = useState([]); - const [globalConfig, setGlobalConfig] = useState(null); + const [globalConfig, setGlobalConfig] = useState(EMPTY_MODEL_ROUTE); const [modelOptions, setModelOptions] = useState([]); const [providerOptions, setProviderOptions] = useState([]); const [error, setError] = useState(null); @@ -362,10 +364,14 @@ export function ReposPage() { api.getModelConfigs(), ]) .then(([reposRes, globalRes, modelsRes]) => { - setRepos(reposRes.repos); - setGlobalConfig(globalRes.config); - setProviderOptions(modelsRes.providers.map(provider => ({ value: provider.id, label: provider.name }))); - setModelOptions(modelsRes.configs.map(config => ({ + const nextRepos = Array.isArray(reposRes?.repos) ? reposRes.repos : []; + const providers = Array.isArray(modelsRes?.providers) ? modelsRes.providers : []; + const configs = Array.isArray(modelsRes?.configs) ? modelsRes.configs : []; + + setRepos(nextRepos); + setGlobalConfig(normalizeRoute(globalRes?.config)); + setProviderOptions(providers.map(provider => ({ value: provider.id, label: provider.name }))); + setModelOptions(configs.map(config => ({ value: config.modelId, label: `${config.providerName} / ${config.modelName}`, providerId: config.providerId, diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 26b4c58..1be0274 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -3,6 +3,7 @@ import { isSupportedGitHubWebhookEvent, type GitHubWebhookEventName, type GitHub import { defaultRepoConfig, normalizeModelId, type ParsedReviewComment, type RepoConfig, type ReviewJobMessage } from '@shared/schema'; import type { AppBindings } from '@server/env'; import { getFileReviewsForJobs, recordRetryableFileReviewFailure, upsertFileReview } from '@server/db/file-reviews'; +import { getResolvedModelConfig } from '@server/db/model-configs'; import { claimJobLease, completeJob, completePreparationStep, failJob, findExistingJobForHead, getJobForProcessing, heartbeatJobLease, insertJob, mapJob, markJobCheckRunCompleted, markJobContinuationQueued, releaseJobLease, supersedeOlderJobs, updateJobCheckRun, updateJobStep } from '@server/db/jobs'; import { filterReviewableFiles, parseUnifiedDiff } from './diff'; @@ -92,6 +93,20 @@ function canInheritParentFileReview(config: RepoConfig, review: { model_used: st return configuredModelSet(config).has(normalizeModelId(review.model_used)); } +async function resolveModelProviderName(env: Pick, modelId: string | null | undefined) { + if (!modelId || modelId === 'unconfigured') return null; + + try { + const resolved = await getResolvedModelConfig(env, normalizeModelId(modelId)); + return resolved?.providerName ?? null; + } catch (error) { + logger.warn(`Failed to resolve provider for model ${modelId}`, { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + function shouldTriggerFromPullRequest(action: PullRequestWebhookPayload['action'], config: RepoConfig['review']) { return (config.on as string[]).includes(action); } @@ -562,10 +577,11 @@ async function reviewAndPersistFile( if (isRetryableModelError(error)) { const modelId = config.model?.main ?? 'unconfigured'; + const modelProvider = await resolveModelProviderName(env, modelId); const failureCount = await recordRetryableFileReviewFailure(env, job.id, { filePath: file.path, modelUsed: modelId, - modelProvider: 'configured', + modelProvider, diffLineCount: file.lineCount, diffInput: '', durationMs: Date.now() - startedAt, @@ -578,7 +594,7 @@ async function reviewAndPersistFile( filePath: file.path, fileStatus: 'failed', modelUsed: modelId, - modelProvider: 'configured', + modelProvider, diffLineCount: file.lineCount, diffInput: '', rawAiOutput: null, @@ -620,11 +636,12 @@ async function reviewAndPersistFile( } const modelId = config.model?.main ?? 'unconfigured'; + const modelProvider = await resolveModelProviderName(env, modelId); await upsertFileReview(env, job.id, { filePath: file.path, fileStatus: 'failed', modelUsed: modelId, - modelProvider: 'configured', + modelProvider, diffLineCount: file.lineCount, diffInput: '', rawAiOutput: null, diff --git a/src/server/db/model-configs.ts b/src/server/db/model-configs.ts index 8a4da92..453f51e 100644 --- a/src/server/db/model-configs.ts +++ b/src/server/db/model-configs.ts @@ -332,6 +332,7 @@ export async function upsertDiscoveredModelConfigs( const uniqueModelNames = Array.from(new Set(input.modelNames.map(name => name.trim()).filter(Boolean))); if (uniqueModelNames.length === 0) return []; + const providerSlug = slugify(input.providerName); const [existingForProvider, existingModelIds] = await Promise.all([ queryRows<{ model_id: string; model_name: string }>( env, @@ -340,13 +341,13 @@ export async function upsertDiscoveredModelConfigs( ), queryRows<{ model_id: string }>( env, - `SELECT model_id FROM model_configs`, + `SELECT model_id FROM model_configs WHERE model_id LIKE $1`, + [`${providerSlug}:%`], ), ]); const existingModelNames = new Set(existingForProvider.map(row => row.model_name)); const usedModelIds = new Set(existingModelIds.map(row => row.model_id)); - const providerSlug = slugify(input.providerName); const rowsToInsert: Array<{ model_id: string; provider_id: string; diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts index 1e58899..8436c75 100644 --- a/src/server/models/cloudflare.ts +++ b/src/server/models/cloudflare.ts @@ -83,6 +83,12 @@ function getNumber(value: unknown, key: string) { return typeof child === 'number' ? child : null; } +function isLocalWorkersAiBindingError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + return normalized.includes('binding ai') && normalized.includes('run remotely'); +} + function synthesizeInconclusiveReview(model: string, reason: string): string { logger.warn(`Cloudflare model ${model} returned no parseable review content; synthesizing inconclusive review JSON`, { reason, @@ -216,7 +222,7 @@ export async function reviewWithCloudflare( lastError = error; const errorMsg = error instanceof Error ? error.message : String(error); - if (errorMsg.includes('Binding AI needs to be run remotely')) { + if (isLocalWorkersAiBindingError(error)) { const message = 'Cloudflare Workers AI is not available in local Wrangler. Run with remote bindings or deploy the Worker to test Cloudflare models.'; logger.warn(message, { model }); throw new ProviderRequestError(providerName, 400, message); From 426bda0e3c494fe4b99dfed0fe6386469f2453e0 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Tue, 26 May 2026 22:16:07 +0530 Subject: [PATCH 6/6] fix: cleanup migrations, improve API robustness, and enhance settings UX --- db/migrations/001_initial.sql | 37 +++------ scripts/migrate.mjs | 22 ++++-- src/client/lib/api.ts | 8 +- src/client/pages/repos.tsx | 22 +++++- src/client/pages/settings.tsx | 136 ++++++++++++++++++++++++---------- src/server/core/github.ts | 43 ++++++----- src/server/core/review.ts | 17 +++-- 7 files changed, 181 insertions(+), 104 deletions(-) diff --git a/db/migrations/001_initial.sql b/db/migrations/001_initial.sql index 2a8a98d..ce8d2e1 100644 --- a/db/migrations/001_initial.sql +++ b/db/migrations/001_initial.sql @@ -31,7 +31,6 @@ CREATE TABLE IF NOT EXISTS repositories ( repo TEXT NOT NULL, UNIQUE(owner, repo) ); -CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); CREATE TABLE IF NOT EXISTS jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -208,33 +207,6 @@ WHERE main_model = '@cf/moonshotai/kimi-k2.5' DROP FUNCTION IF EXISTS public.codra_replace_deprecated_model(jsonb, text, text); -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -CREATE TABLE IF NOT EXISTS repositories ( - installation_id BIGINT NOT NULL, - id SERIAL PRIMARY KEY, - owner TEXT NOT NULL, - repo TEXT NOT NULL, - UNIQUE(owner, repo) -); - -CREATE INDEX IF NOT EXISTS repositories_owner_idx ON repositories(owner); - -CREATE TABLE IF NOT EXISTS review_comments ( - file_review_id UUID NOT NULL REFERENCES file_reviews(id) ON DELETE CASCADE, - id BIGSERIAL PRIMARY KEY, - line INTEGER, - position INTEGER, - path TEXT NOT NULL, - severity TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'quality', - title TEXT NOT NULL, - body TEXT COMPRESSION lz4 NOT NULL, - code_suggestion TEXT COMPRESSION lz4 -); - -CREATE INDEX IF NOT EXISTS review_comments_file_idx ON review_comments(file_review_id); - DO $$ DECLARE has_old_job_repo_columns BOOLEAN; @@ -601,6 +573,15 @@ WHERE mc.provider_id IS NULL OR (mc.provider = 'anthropic' AND provider_record.name = 'Anthropic') ); +UPDATE model_configs mc +SET + provider_id = provider_record.id, + model_name = COALESCE(mc.model_name, mc.model_id), + provider = 'cloudflare' +FROM llm_providers provider_record +WHERE mc.provider_id IS NULL + AND provider_record.name = 'Cloudflare'; + UPDATE model_configs SET model_name = model_id WHERE model_name IS NULL; diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 98291cb..29823d7 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -280,14 +280,6 @@ async function ensureModelCatalog() { WHERE rpm = 1 AND tpm = 1 AND rpd = 1 `); - await query( - ` - INSERT INTO llm_providers (name, api_format, base_url, enabled) - VALUES ('Cloudflare', 'cloudflare-workers-ai', NULL, TRUE) - ON CONFLICT (name) DO NOTHING - `, - ); - await query( ` UPDATE model_configs mc @@ -306,6 +298,19 @@ async function ensureModelCatalog() { `, ); + await query( + ` + UPDATE model_configs mc + SET + provider_id = provider_record.id, + model_name = COALESCE(mc.model_name, mc.model_id), + provider = 'cloudflare' + FROM llm_providers provider_record + WHERE mc.provider_id IS NULL + AND provider_record.name = 'Cloudflare' + `, + ); + await query('UPDATE model_configs SET model_name = model_id WHERE model_name IS NULL'); await query( @@ -466,6 +471,7 @@ async function main() { } } + await query('DROP INDEX IF EXISTS repositories_owner_idx'); await ensureModelCatalog(); await normalizeRepoConfigs(); diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 7148d52..929f784 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -24,8 +24,8 @@ function pathSegment(value: string) { } type QueryValue = string | number | boolean | null | undefined; -type ModelConfigPayload = Pick; -type ProviderPayload = { +export type ModelConfigPayload = Pick; +export type ProviderPayload = { name: string; apiFormat: LlmApiFormat; baseUrl: string | null; @@ -162,7 +162,7 @@ export const api = { return request('/api/repos'); }, getRepo(owner: string, repo: string) { - return request(`/api/repos/${owner}/${repo}/config`); + return request(`/api/repos/${pathSegment(owner)}/${pathSegment(repo)}/config`); }, getStats(days?: number) { const query = days ? `?days=${days}` : ''; @@ -189,7 +189,7 @@ export const api = { }); }, updateRepoConfig(owner: string, repo: string, config: RepoConfigPatch) { - return request<{ ok: boolean }>(`/api/repos/${owner}/${repo}/config`, { + return request<{ ok: boolean }>(`/api/repos/${pathSegment(owner)}/${pathSegment(repo)}/config`, { method: 'PATCH', body: JSON.stringify(config), }); diff --git a/src/client/pages/repos.tsx b/src/client/pages/repos.tsx index 8b599ee..8332449 100644 --- a/src/client/pages/repos.tsx +++ b/src/client/pages/repos.tsx @@ -67,8 +67,28 @@ function getStoredRepoRoute(repo: RepoConfigRecord): ModelRouteConfig | null { }; } +function stringArraysEqual(a: string[] = [], b: string[] = []) { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function tiersEqual(a: ModelRouteConfig['size_overrides'] = [], b: ModelRouteConfig['size_overrides'] = []) { + return a.length === b.length && a.every((tier, index) => { + const other = b[index]; + return Boolean( + other && + tier.max_lines === other.max_lines && + tier.model === other.model && + stringArraysEqual(tier.fallbacks ?? [], other.fallbacks ?? []), + ); + }); +} + function routesEqual(a: ModelRouteConfig, b: ModelRouteConfig) { - return JSON.stringify(a) === JSON.stringify(b); + return ( + a.main === b.main && + stringArraysEqual(a.fallbacks ?? [], b.fallbacks ?? []) && + tiersEqual(a.size_overrides ?? [], b.size_overrides ?? []) + ); } function hasMeaningfulCustomStrategy(repo: RepoConfigRecord, globalConfig: GlobalModelConfig | ModelRouteConfig | null) { diff --git a/src/client/pages/settings.tsx b/src/client/pages/settings.tsx index a1089e4..3745060 100644 --- a/src/client/pages/settings.tsx +++ b/src/client/pages/settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { api } from '@client/lib/api'; +import { api, type ProviderPayload } from '@client/lib/api'; import { PageHeader } from '@client/components/layout/page-header'; import { Button } from '@client/components/ui/button'; import { Alert } from '@client/components/ui/alert'; @@ -24,7 +24,7 @@ import { ChevronRight, X, } from 'lucide-react'; -import type { LlmApiFormat, LlmProvider, ModelConfig } from '@shared/schema'; +import type { LlmApiFormat, LlmProvider, ModelConfig, RepoConfig } from '@shared/schema'; import type { ModelConfigsResponse } from '@shared/api'; import { ModelRouteEditor, @@ -75,7 +75,9 @@ type NewModelDraft = { type SyncError = { providerId: string; providerName: string; error: string }; -export function normalizeGlobalConfig(config: any): ModelRouteConfig { +type GlobalConfigInput = RepoConfig['model'] | Partial | null | undefined; + +export function normalizeGlobalConfig(config: GlobalConfigInput): ModelRouteConfig { return { main: typeof config?.main === 'string' && config.main.trim() ? config.main : null, fallbacks: Array.isArray(config?.fallbacks) ? config.fallbacks : EMPTY_GLOBAL_CONFIG.fallbacks, @@ -83,8 +85,30 @@ export function normalizeGlobalConfig(config: any): ModelRouteConfig { }; } +function stringArraysEqual(a: string[] = [], b: string[] = []) { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function tiersEqual(a: ModelRouteConfig['size_overrides'] = [], b: ModelRouteConfig['size_overrides'] = []) { + return a.length === b.length && a.every((tier, index) => { + const other = b[index]; + return Boolean( + other && + tier.max_lines === other.max_lines && + tier.model === other.model && + stringArraysEqual(tier.fallbacks ?? [], other.fallbacks ?? []), + ); + }); +} + function routeEqual(a: ModelRouteConfig | null, b: ModelRouteConfig | null) { - return JSON.stringify(a) === JSON.stringify(b); + if (a === b) return true; + if (!a || !b) return false; + return ( + a.main === b.main && + stringArraysEqual(a.fallbacks ?? [], b.fallbacks ?? []) && + tiersEqual(a.size_overrides ?? [], b.size_overrides ?? []) + ); } function configEqual(a?: ModelConfig, b?: ModelConfig) { @@ -127,6 +151,10 @@ function formatLabel(format: LlmApiFormat) { return API_FORMAT_OPTIONS.find(option => option.value === format)?.label ?? format; } +function domId(prefix: string, value: string) { + return `${prefix}-${value.replace(/[^a-zA-Z0-9_-]+/g, '-')}`; +} + function isCustomProvider(provider: Pick) { return provider.apiFormat !== 'cloudflare-workers-ai' && !FIXED_PROVIDER_NAMES.has(provider.name); } @@ -189,11 +217,11 @@ function SectionCard({ } /* ─── Field label ─────────────────────────────────────────────────────────── */ -function FieldLabel({ children }: { children: React.ReactNode }) { +function FieldLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) { return ( - + + ); } @@ -424,7 +452,7 @@ export function SettingsPage() { setError(null); const tid = toast.loading('Saving provider...'); try { - const payload: any = { + const payload: ProviderPayload = { name: provider.name, apiFormat: provider.apiFormat, baseUrl: provider.baseUrl || null, @@ -533,11 +561,23 @@ export function SettingsPage() { setError(null); const tid = toast.loading(`Saving ${dirtyConfigs.length} model change${dirtyConfigs.length === 1 ? '' : 's'}...`); try { - const saved = await Promise.all(dirtyConfigs.map(cfg => api.updateModelConfig(cfg.modelId, modelPayload(cfg)))); + const results = await Promise.allSettled(dirtyConfigs.map(cfg => api.updateModelConfig(cfg.modelId, modelPayload(cfg)))); + const saved = results + .filter((result): result is PromiseFulfilledResult>> => result.status === 'fulfilled') + .map(result => result.value); + const failed = results.length - saved.length; + const savedById = new Map(saved.map(result => [result.config.modelId, result.config])); setConfigs(current => current.map(cfg => savedById.get(cfg.modelId) ?? cfg)); setSavedConfigs(current => current.map(cfg => savedById.get(cfg.modelId) ?? cfg)); - toast.success('Models saved', { id: tid }); + + if (failed > 0) { + const msg = `${failed} model update${failed === 1 ? '' : 's'} failed. Saved ${saved.length}.`; + setError(msg); + toast.error('Some models were not saved', { id: tid, description: msg }); + } else { + toast.success('Models saved', { id: tid }); + } } catch (e) { const msg = e instanceof Error ? e.message : 'Update failed'; setError(msg); @@ -690,26 +730,25 @@ export function SettingsPage() {

New provider

+ { - const preset = PROVIDER_PRESETS.find(item => item.value === value) ?? PROVIDER_PRESETS[0]; - setNewProvider(current => ({ - ...current, - preset: preset.value, - name: preset.name, - apiFormat: preset.apiFormat, - baseUrl: preset.baseUrl, - })); - }} - options={PROVIDER_PRESETS.map(preset => ({ value: preset.value, label: preset.label }))} - /> -
-
- Name + Name setNewProvider(current => ({ ...current, name: e.target.value }))} @@ -719,16 +758,18 @@ export function SettingsPage() { )}
- Base URL + Base URL setNewProvider(current => ({ ...current, baseUrl: e.target.value }))} />
- API Key + API Key
- Name + Name updateProviderDraft(provider.id, { name: e.target.value })} /> @@ -871,8 +916,9 @@ export function SettingsPage() { options={API_FORMAT_OPTIONS.filter(option => option.value !== 'cloudflare-workers-ai')} />
- Base URL + Base URL updateProviderDraft(provider.id, { baseUrl: e.target.value || null })} @@ -886,8 +932,9 @@ export function SettingsPage() {

) : (
- API Key + API Key New model

- Codra model ID + Codra model ID setNewModel(current => ({ ...current, modelId: e.target.value }))} />
- Provider model name + Provider model name setNewModel(current => ({ ...current, modelName: e.target.value }))} @@ -1017,8 +1066,9 @@ export function SettingsPage() {
{(['rpm', 'rpd', 'tpm'] as const).map(field => (
- {field.toUpperCase()} + {field.toUpperCase()} item.modelId === cfg.modelId); const dirty = !configEqual(cfg, saved); const expanded = expandedModelId === cfg.modelId; + const providerModelNameId = domId('model-provider-name', cfg.modelId); return (
- Provider model name + Provider model name updateModel(cfg.modelId, { modelName: e.target.value })} />
- {(['rpm', 'rpd', 'tpm'] as const).map(field => ( + {(['rpm', 'rpd', 'tpm'] as const).map(field => { + const limitId = domId(`model-${field}`, cfg.modelId); + return (
- {field.toUpperCase()} + {field.toUpperCase()} updateQuota(cfg.modelId, field, parseOptionalLimit(e.target.value))} />
- ))} + ); + })}
diff --git a/src/server/core/github.ts b/src/server/core/github.ts index 1e2d8df..c651479 100644 --- a/src/server/core/github.ts +++ b/src/server/core/github.ts @@ -108,6 +108,10 @@ function encodeGitHubContentPath(path: string) { return path.split('/').map((segment) => encodeURIComponent(segment)).join('/'); } +function repoApiPath(owner: string, repo: string) { + return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; +} + function pemToArrayBuffer(pem: string) { const base64 = pem .replace(/-----BEGIN (RSA )?PRIVATE KEY-----/g, '') @@ -374,7 +378,7 @@ export class GitHubClient { async getPullRequest(owner: string, repo: string, pullNumber: number) { return withRetry(`getPullRequest ${owner}/${repo}#${pullNumber}`, async () => { - const response = await this.requestAndCheck(`/repos/${owner}/${repo}/pulls/${pullNumber}`); + const response = await this.requestAndCheck(`${repoApiPath(owner, repo)}/pulls/${pullNumber}`); return (await response.json()) as PullRequestRecord; }); } @@ -382,7 +386,7 @@ export class GitHubClient { async getPullRequestDiff(owner: string, repo: string, pullNumber: number) { return withRetry(`getPullRequestDiff ${owner}/${repo}#${pullNumber}`, async () => { const response = await this.requestAndCheck( - `/repos/${owner}/${repo}/pulls/${pullNumber}`, + `${repoApiPath(owner, repo)}/pulls/${pullNumber}`, {}, 'application/vnd.github.v3.diff', ); @@ -392,7 +396,7 @@ export class GitHubClient { async getRepoFileOrNull(owner: string, repo: string, path: string) { return withRetry(`getRepoFileOrNull ${owner}/${repo}/${path}`, async () => { - const response = await this.request(`/repos/${owner}/${repo}/contents/${encodeGitHubContentPath(path)}`); + const response = await this.request(`${repoApiPath(owner, repo)}/contents/${encodeGitHubContentPath(path)}`); if (response.status === 404) { return null; } @@ -421,7 +425,7 @@ export class GitHubClient { input: { headSha: string; title: string; summary: string; detailsUrl?: string }, ) { return withRetry(`createCheckRun ${owner}/${repo}`, async () => { - const response = await this.requestAndCheck(`/repos/${owner}/${repo}/check-runs`, { + const response = await this.requestAndCheck(`${repoApiPath(owner, repo)}/check-runs`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -454,7 +458,7 @@ export class GitHubClient { }, ) { return withRetry(`updateCheckRun ${owner}/${repo} ${checkRunId}`, async () => { - await this.requestAndCheck(`/repos/${owner}/${repo}/check-runs/${checkRunId}`, { + await this.requestAndCheck(`${repoApiPath(owner, repo)}/check-runs/${checkRunId}`, { method: 'PATCH', headers: { 'content-type': 'application/json', @@ -497,7 +501,8 @@ export class GitHubClient { })), }; - let response = await this.request(`/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, { + const reviewPath = `${repoApiPath(owner, repo)}/pulls/${pullNumber}/reviews`; + let response = await this.request(reviewPath, { method: 'POST', headers: { 'content-type': 'application/json', @@ -511,7 +516,7 @@ export class GitHubClient { repo, pullNumber, }); - response = await this.request(`/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, { + response = await this.request(reviewPath, { method: 'POST', headers: { 'content-type': 'application/json', @@ -530,7 +535,7 @@ export class GitHubClient { throw new GitHubError( response.status, errText, - `/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, + reviewPath, `GitHub review creation failed with ${response.status}: ${errText}`, ); } @@ -541,7 +546,7 @@ export class GitHubClient { async ensureLabel(owner: string, repo: string, name: string, color: string) { return withRetry(`ensureLabel ${owner}/${repo} ${name}`, async () => { - const listResponse = await this.request(`/repos/${owner}/${repo}/labels/${encodeURIComponent(name)}`); + const listResponse = await this.request(`${repoApiPath(owner, repo)}/labels/${encodeURIComponent(name)}`); if (listResponse.ok) { return; } @@ -555,7 +560,7 @@ export class GitHubClient { ); } - const createResponse = await this.request(`/repos/${owner}/${repo}/labels`, { + const createResponse = await this.request(`${repoApiPath(owner, repo)}/labels`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -577,7 +582,7 @@ export class GitHubClient { async addIssueLabels(owner: string, repo: string, issueNumber: number, labels: string[]) { return withRetry(`addIssueLabels ${owner}/${repo}#${issueNumber}`, async () => { - await this.requestAndCheck(`/repos/${owner}/${repo}/issues/${issueNumber}/labels`, { + await this.requestAndCheck(`${repoApiPath(owner, repo)}/issues/${issueNumber}/labels`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -589,10 +594,13 @@ export class GitHubClient { async listIssueLabels(owner: string, repo: string, issueNumber: number) { return withRetry(`listIssueLabels ${owner}/${repo}#${issueNumber}`, async () => { - const response = await this.requestAndCheck(`/repos/${owner}/${repo}/issues/${issueNumber}/labels?per_page=100`); - const labels = (await response.json()) as GitHubIssueLabel[]; + const response = await this.requestAndCheck(`${repoApiPath(owner, repo)}/issues/${issueNumber}/labels?per_page=100`); + const labels = await response.json(); + if (!Array.isArray(labels)) { + throw new Error('Expected an array of labels from GitHub API.'); + } return labels - .map(label => label.name) + .map((label: GitHubIssueLabel) => label.name) .filter((name): name is string => typeof name === 'string' && name.length > 0); }); } @@ -601,18 +609,19 @@ export class GitHubClient { const currentLabels = await this.listIssueLabels(owner, repo, issueNumber); const currentByLowerName = new Map(currentLabels.map(label => [label.toLowerCase(), label])); - for (const label of labels) { + const uniqueLabels = Array.from(new Set(labels.map(label => label.toLowerCase()))); + await Promise.all(uniqueLabels.map(async (label) => { const currentLabel = currentByLowerName.get(label.toLowerCase()); if (currentLabel) { await this.removeIssueLabel(owner, repo, issueNumber, currentLabel); } - } + })); } async removeIssueLabel(owner: string, repo: string, issueNumber: number, label: string) { return withRetry(`removeIssueLabel ${owner}/${repo}#${issueNumber} ${label}`, async () => { const response = await this.request( - `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`, + `${repoApiPath(owner, repo)}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`, { method: 'DELETE', }, diff --git a/src/server/core/review.ts b/src/server/core/review.ts index 1be0274..15ae035 100644 --- a/src/server/core/review.ts +++ b/src/server/core/review.ts @@ -439,6 +439,12 @@ async function runReviewPhase( const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber); const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig; + const failureModelId = config.model?.main ?? 'unconfigured'; + let failureModelProviderPromise: Promise | null = null; + const resolveFailureModelProvider = () => { + failureModelProviderPromise ??= resolveModelProviderName(env, failureModelId); + return failureModelProviderPromise; + }; const rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber); const files = filterReviewableFiles(parseUnifiedDiff(rawDiff), config.review); const totalLineCount = files.reduce((sum, file) => sum + file.lineCount, 0); @@ -462,13 +468,13 @@ async function runReviewPhase( const inherited = parentReviews.get(file.path); const reviewTask = async () => { if (!inherited) { - await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, existingReview); + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview); return; } if (!canInheritParentFileReview(config, inherited)) { logger.info(`Ignoring inherited review for ${file.path}; parent model ${inherited.model_used} is not in the current model strategy`); - await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, existingReview); + await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview); } else { await upsertFileReview(env, job.id, { filePath: file.path, @@ -540,6 +546,7 @@ async function reviewAndPersistFile( config: RepoConfig, totalLineCount: number, model: ModelService, + resolveFailureModelProvider: () => Promise, previousReview?: { transient_error_count: number }, ) { const startedAt = Date.now(); @@ -574,10 +581,10 @@ async function reviewAndPersistFile( }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown file review error'; + const modelId = config.model?.main ?? 'unconfigured'; + const modelProvider = await resolveFailureModelProvider(); if (isRetryableModelError(error)) { - const modelId = config.model?.main ?? 'unconfigured'; - const modelProvider = await resolveModelProviderName(env, modelId); const failureCount = await recordRetryableFileReviewFailure(env, job.id, { filePath: file.path, modelUsed: modelId, @@ -635,8 +642,6 @@ async function reviewAndPersistFile( throw error; } - const modelId = config.model?.main ?? 'unconfigured'; - const modelProvider = await resolveModelProviderName(env, modelId); await upsertFileReview(env, job.id, { filePath: file.path, fileStatus: 'failed',