{"repo": "bilal-chajia/freecipies-blog", "files": {"docs/superpowers/plans/2026-06-04-ai-model-management-redesign.md": {"7ca1191cd9c038f198439b53ef34701bed6c9f78": {"t": 1785746576.6596735, "n": 47698, "h": "8e72655065098b35", "c": "# AI Model Management Redesign Implementation Plan\n\n> **Status: COMPLETED** (all tasks done; module shipped with tests).\n\n> **STATUS — 2026-06-04: ✅ COMPLETED, REVIEWED & FIXED.** All phases implemented (snake_case types/store/discovery/providers/custom providers/endpoints/reasoning/admin UI + data migration). Code review fixes applied (`a71be82`: anthropic max_tokens>budget, provider-id collision guard, un-deprecate, editor reasoning_effort). Stored `ai_settings` blob migrated locally (`migrate-ai-settings --apply`). Verified: typecheck 0 · tests green · boundaries ✅. Ops: run `migrate-ai-settings --apply` on prod D1 at deploy.\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Replace the hardcoded AI model catalog with live per-provider discovery, store only the admin's curated snake_case selection with safe validated persistence and write-only keys, add OpenAI-compatible custom providers, model-obsolescence reconciliation, and a thinking/reasoning control — closing the AI portion of Contract Audit #3.\n\n**Architecture:** Pure-logic units (snake_case types, Zod stored-blob schema, deep-merge persistence, key masking, model normalization/modality classification, obsolescence reconciliation) are built test-first. Provider `listModels` implementations share one OpenAI-style normalizer. API routes, the admin UI, and the client are migrated to snake_case and wired to discovery. A one-shot script migrates the existing stored blob.\n\n**Tech Stack:** Cloudflare D1 (`site_settings`), Zod, Astro API routes, React admin SPA, Vitest, `node:sqlite` for the migration script.\n\n**Spec:** `docs/superpowers/specs/2026-06-04-ai-model-management-redesign-design.md`\n\n---\n\n## Conventions (read once)\n\n- **snake_case** for all app-owned data keys (stored blob, our API envelopes, admin props, types\n that model them). Local variables/functions/React state may stay camelCase (`NAMING_CONTRACT`\n L21). External provider HTTP request/response field names stay as the provider names **only\n inside each provider class** at the fetch boundary (Allowed Exception).\n- **TDD** for pure units (types are compile-time; logic units get a failing test first). Mechanical\n snake_case renames and UI wiring are verified by `pnpm test && pnpm check:boundaries &&\n pnpm typecheck`.\n- **Run between every task:** `pnpm test && pnpm check:boundaries && pnpm typecheck`. All must be\n green before the task's commit.\n- Test files live next to the code under `__tests__/` per existing convention\n (e.g. `src/modules/ai/__tests__/...`).\n\n## File structure (created / modified)\n\n- `src/modules/ai/types.ts` — replace AISettings/AIModel/AVAILABLE_MODELS with snake_case\n `AiSettings`, `ModelSelection`, `DiscoveredModel`, capability enums; keep `ALL_PROVIDERS`,\n `PROVIDER_INFO`, `DEFAULT_AI_SETTINGS` (snake).\n- `src/modules/ai/settings-schema.ts` (new) — Zod schema for the stored blob.\n- `src/modules/ai/settings-store.ts` (new) — `getAiSettings` / `saveAiSettings` (deep merge +\n validate) extracted from `ai.service.ts`.\n- `src/modules/ai/api-key.ts` (new) — `maskApiKey`, `stripApiKeys`.\n- `src/modules/ai/discovery/normalize.ts` (new) — `normalizeOpenAiModels`, `classifyModality`,\n `detectThinking`.\n- `src/modules/ai/discovery/reconcile.ts` (new) — `reconcileSelection`.\n- `src/modules/ai/providers/openai-compatible.provider.ts` (new) — generic custom provider.\n- `src/modules/ai/providers/*.provider.ts` — add `listModels`; snake_case app-owned fields.\n- `src/modules/ai/ai.service.ts` — discovery resolution, generate with `reasoning_effort`,\n open-ended factory.\n- `src/shared/validation/schemas/ai.ts` — snake_case Zod schemas + `reasoning_effort`.\n- `src/pages/api/admin/ai/models/[provider]/discover.ts` (new) — discovery endpoint.\n- `src/pages/api/admin/ai/settings.ts`, `providers.ts`, `generate.ts`,\n `models/[provider]/[modelId].ts`, `models/[provider].ts` — snake_case + masking + custom CRUD.\n- `src/pages/api/admin/ai/migrate-models.ts` — **delete**.\n- `src/pages/api/admin/ai/custom-providers/*` (new) — custom provider CRUD.\n- `src/admin/components/ModelManager.tsx`, `BulkImportModels.tsx`,\n `src/admin/features/settings/pages/tabs/AISettings.tsx`,\n `src/admin/components/BlockEditor/components/AISettings.tsx`,\n `src/admin/features/settings/pages/Settings.tsx`, `src/admin/services/api.ts` — snake_case +\n discovery UX + `has_api_key`.\n- `scripts/migrate-ai-settings.mts` (new) — one-shot stored-blob migration.\n\n---\n\n## Phase 0 — snake_case types & defaults\n\n**Files:**\n- Modify: `src/modules/ai/types.ts`\n\n- [x] **Step 1: Replace the data-shape interfaces with snake_case**\n\nReplace `AIModel`, `AISettings`, `DEFAULT_AI_SETTINGS`, and remove `AVAILABLE_MODELS`. Keep\n`AIProvider`, `ALL_PROVIDERS`, `PROVIDER_INFO`, `IAIProvider`, `GenerateContentRequest`,\n`GeneratedRecipe`. New definitions:\n\n```ts\n/** Built-in provider id, or a custom OpenAI-compatible provider id (slug). */\nexport type BuiltInProvider =\n | 'gemini' | 'openai' | 'anthropic' | 'deepseek' | 'openrouter'\n | 'qwen' | 'zhipu' | 'moonshot' | 'mistral' | 'xai';\nexport type AIProvider = BuiltInProvider | (string & {});\n\nexport type ModelModality = 'text' | 'image' | 'audio' | 'embedding' | 'other';\nexport type ModelStatus = 'available' | 'unavailable' | 'deprecated';\n\n/** A model the provider currently offers (discovery output). */\nexport interface DiscoveredModel {\n id: string;\n name?: string;\n context_window?: number;\n max_tokens?: number;\n modality: ModelModality;\n supports_thinking?: boolean;\n deprecated?: boolean;\n}\n\n/** A model the admin has curated into the stored selection. */\nexport interface ModelSelection {\n id: string;\n name?: string;\n context_window?: number;\n max_tokens?: number;\n modality: ModelModality;\n supports_thinking?: boolean;\n enabled: boolean;\n order: number;\n deprecated?: boolean;\n status: ModelStatus;\n source: 'discovered' | 'manual';\n}\n\nexport interface ProviderConfig {\n enabled: boolean;\n api_key: string;\n models: ModelSelection[];\n}\n\nexport interface CustomProviderConfig extends ProviderConfig {\n label: string;\n base_url: string;\n}\n\nexport interface AiSettings {\n default_provider: AIProvider;\n default_model: string;\n temperature: number;\n system_prompt: string;\n providers: Partial>;\n custom_providers: Record;\n}\n\nexport const DEFAULT_AI_SETTINGS: AiSettings = {\n default_provider: 'gemini',\n default_model: 'gemini-3-flash-preview',\n temperature: 0.7,\n system_prompt: `You are an expert culinary content creator. Generate structured recipe and article content in JSON format.\nAlways respond with valid JSON that matches the expected schema.\nBe creative, detailed, and provide practical cooking tips.`,\n providers: {},\n custom_providers: {},\n};\n```\n\nUpdate `GenerateContentRequest` to snake_case app-owned fields and add the reasoning control:\n\n```ts\nexport interface GenerateContentRequest {\n prompt: string;\n content_type: 'recipe' | 'article' | 'roundup';\n provider: AIProvider;\n model: string;\n temperature?: number;\n system_prompt?: string;\n reasoning_effort?: 'low' | 'medium' | 'high';\n}\n```\n\n- [x] **Step 2: Typecheck (expect downstream breakages — they are fixed in later phases)**\n\nRun: `pnpm typecheck 2>&1 | head -40`\nExpected: errors only in AI files that consume the old names (ai.service, providers, endpoints,\nadmin). These are addressed in Phases 1–7. Do NOT fix unrelated files here.\n\n- [x] **Step 3: Commit**\n\n```bash\ngit add src/modules/ai/types.ts\ngit commit -m \"refactor(ai): snake_case AiSettings + add discovery/selection model types\"\n```\n\n> Note for the executor: Phases 0–7 form one compile-coherent unit. Commit per phase, but\n> `pnpm typecheck` only returns fully green at the end of Phase 7. Phases that CAN be green in\n> isolation (1, 2, 3, 8) state so; Phases 0, 4–7 may leave typecheck red until Phase 7. Each\n> phase's own unit tests must pass.\n\n---\n\n## Phase 1 — safe persistence, validation, key masking\n\n**Files:**\n- Create: `src/modules/ai/settings-schema.ts`\n- Create: `src/modules/ai/api-key.ts`\n- Create: `src/modules/ai/settings-store.ts`\n- Create: `src/modules/ai/__tests__/settings-store.test.ts`\n- Create: `src/modules/ai/__tests__/api-key.test.ts`\n\n- [x] **Step 1: Write failing tests for api-key masking**\n\n`src/modules/ai/__tests__/api-key.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { maskApiKey, stripApiKeys } from '../api-key';\n\ndescribe('maskApiKey', () => {\n it('masks all but the last 4 chars', () => {\n expect(maskApiKey('sk-abcdef1234')).toBe('••••1234');\n });\n it('returns empty string for empty/undefined', () => {\n expect(maskApiKey('')).toBe('');\n expect(maskApiKey(undefined)).toBe('');\n });\n});\n\ndescribe('stripApiKeys', () => {\n it('replaces api_key with has_api_key + masked across providers and custom_providers', () => {\n const out = stripApiKeys({\n default_provider: 'openai', default_model: 'm', temperature: 0.7, system_prompt: 's',\n providers: { openai: { enabled: true, api_key: 'sk-xxxx9999', models: [] } },\n custom_providers: { c1: { label: 'C', base_url: 'u', enabled: true, api_key: 'k-1111', models: [] } },\n });\n expect(out.providers.openai).toMatchObject({ has_api_key: true, api_key_masked: '••••9999' });\n expect('api_key' in out.providers.openai).toBe(false);\n expect(out.custom_providers.c1).toMatchObject({ has_api_key: true, api_key_masked: '••••1111' });\n expect('api_key' in out.custom_providers.c1).toBe(false);\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/api-key.test.ts` → Expected: FAIL (module missing).\n\n- [x] **Step 2: Implement `api-key.ts`**\n\n```ts\nimport type { AiSettings } from './types';\n\nexport function maskApiKey(key: string | undefined): string {\n if (!key) return '';\n return `••••${key.slice(-4)}`;\n}\n\ntype StrippedProvider = { enabled: boolean; models: unknown[]; has_api_key: boolean; api_key_masked: string };\n\nfunction strip(cfg: T): Omit & { has_api_key: boolean; api_key_masked: string } {\n const { api_key, ...rest } = cfg;\n return { ...rest, has_api_key: Boolean(api_key), api_key_masked: maskApiKey(api_key) };\n}\n\nexport function stripApiKeys(settings: AiSettings) {\n return {\n ...settings,\n providers: Object.fromEntries(\n Object.entries(settings.providers).map(([k, v]) => [k, strip(v!)]),\n ),\n custom_providers: Object.fromEntries(\n Object.entries(settings.custom_providers).map(([k, v]) => [k, strip(v)]),\n ),\n };\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/api-key.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Implement the Zod stored-blob schema (`settings-schema.ts`)**\n\n```ts\nimport { z } from 'zod';\n\nconst ModelSelectionSchema = z.object({\n id: z.string().min(1),\n name: z.string().optional(),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n modality: z.enum(['text', 'image', 'audio', 'embedding', 'other']).default('text'),\n supports_thinking: z.boolean().optional(),\n enabled: z.boolean().default(true),\n order: z.number().int().nonnegative().default(0),\n deprecated: z.boolean().optional(),\n status: z.enum(['available', 'unavailable', 'deprecated']).default('available'),\n source: z.enum(['discovered', 'manual']).default('discovered'),\n});\n\nconst ProviderConfigSchema = z.object({\n enabled: z.boolean().default(false),\n api_key: z.string().default(''),\n models: z.array(ModelSelectionSchema).default([]),\n});\n\nconst CustomProviderConfigSchema = ProviderConfigSchema.extend({\n label: z.string().min(1),\n base_url: z.string().url(),\n});\n\nexport const AiSettingsSchema = z.object({\n default_provider: z.string().min(1),\n default_model: z.string().default(''),\n temperature: z.number().min(0).max(2).default(0.7),\n system_prompt: z.string().default(''),\n providers: z.record(z.string(), ProviderConfigSchema).default({}),\n custom_providers: z.record(z.string(), CustomProviderConfigSchema).default({}),\n});\n```\n\n- [x] **Step 4: Write failing tests for the store (deep merge + validate + fallback)**\n\n`src/modules/ai/__tests__/settings-store.test.ts`:\n```ts\nimport { describe, it, expect, vi } from 'vitest';\nimport { mergeAiSettings } from '../settings-store';\nimport { DEFAULT_AI_SETTINGS } from '../types';\n\ndescribe('mergeAiSettings', () => {\n it('deep-merges one provider without dropping others', () => {\n const current = {\n ...DEFAULT_AI_SETTINGS,\n providers: {\n openai: { enabled: true, api_key: 'sk-1', models: [] },\n gemini: { enabled: true, api_key: 'g-1', models: [] },\n },\n };\n const merged = mergeAiSettings(current, { providers: { openai: { enabled: false } } });\n expect(merged.providers.gemini).toEqual({ enabled: true, api_key: 'g-1', models: [] });\n expect(merged.providers.openai).toMatchObject({ enabled: false, api_key: 'sk-1' });\n });\n\n it('keeps existing api_key when the patch omits it', () => {\n const current = { ...DEFAULT_AI_SETTINGS, providers: { openai: { enabled: true, api_key: 'sk-keep', models: [] } } };\n const merged = mergeAiSettings(current, { providers: { openai: { models: [] } } });\n expect(merged.providers.openai.api_key).toBe('sk-keep');\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/settings-store.test.ts` → Expected: FAIL.\n\n- [x] **Step 5: Implement `settings-store.ts`**\n\n```ts\nimport type { D1Database } from '@cloudflare/workers-types';\nimport type { AiSettings } from './types';\nimport { DEFAULT_AI_SETTINGS } from './types';\nimport { AiSettingsSchema } from './settings-schema';\n\nconst AI_SETTINGS_KEY = 'ai_settings';\n\nfunction mergeProviderMap>(current: T, patch: Partial | undefined): T {\n if (!patch) return current;\n const out: Record = { ...current };\n for (const [id, cfg] of Object.entries(patch)) {\n out[id] = { ...(current[id] ?? {}), ...(cfg as object) };\n }\n return out as T;\n}\n\nexport function mergeAiSettings(current: AiSettings, patch: Partial): AiSettings {\n return {\n ...current,\n ...patch,\n providers: mergeProviderMap(current.providers, patch.providers),\n custom_providers: mergeProviderMap(current.custom_providers, patch.custom_providers),\n };\n}\n\nexport async function getAiSettings(db: D1Database): Promise {\n try {\n const row = await db.prepare('SELECT value FROM site_settings WHERE key = ?')\n .bind(AI_SETTINGS_KEY).first<{ value: string }>();\n if (row?.value) {\n const parsed = AiSettingsSchema.safeParse(JSON.parse(row.value));\n if (parsed.success) return parsed.data as AiSettings;\n console.error('Invalid ai_settings blob, using defaults:', parsed.error.issues);\n }\n } catch (e) {\n console.error('Failed to load AI settings:', e);\n }\n return DEFAULT_AI_SETTINGS;\n}\n\nexport async function saveAiSettings(db: D1Database, patch: Partial): Promise {\n try {\n const merged = mergeAiSettings(await getAiSettings(db), patch);\n const validated = AiSettingsSchema.parse(merged);\n await db.prepare(`\n INSERT INTO site_settings (key, value, category, type, updated_at)\n VALUES (?, ?, 'ai', 'json', CURRENT_TIMESTAMP)\n ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP\n `).bind(AI_SETTINGS_KEY, JSON.stringify(validated)).run();\n return true;\n } catch (e) {\n console.error('Failed to save AI settings:', e);\n return false;\n }\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/settings-store.test.ts src/modules/ai/__tests__/api-key.test.ts`\n→ Expected: PASS.\n\n- [x] **Step 6: Commit**\n\n```bash\ngit add src/modules/ai/settings-schema.ts src/modules/ai/api-key.ts src/modules/ai/settings-store.ts src/modules/ai/__tests__/settings-store.test.ts src/modules/ai/__tests__/api-key.test.ts\ngit commit -m \"feat(ai): validated deep-merge settings store + write-only key masking\"\n```\n\n---\n\n## Phase 2 — discovery core (normalize, classify, reconcile) + openai listModels\n\n**Files:**\n- Create: `src/modules/ai/discovery/normalize.ts`\n- Create: `src/modules/ai/discovery/reconcile.ts`\n- Create: `src/modules/ai/__tests__/normalize.test.ts`\n- Create: `src/modules/ai/__tests__/reconcile.test.ts`\n- Modify: `src/modules/ai/types.ts` (add `listModels` to `IAIProvider`)\n- Modify: `src/modules/ai/providers/openai.provider.ts`\n\n- [x] **Step 1: Write failing tests for `normalize.ts`**\n\n`src/modules/ai/__tests__/normalize.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { classifyModality, detectThinking, normalizeOpenAiModels } from '../discovery/normalize';\n\ndescribe('classifyModality', () => {\n it('classifies known non-text ids', () => {\n expect(classifyModality('dall-e-3')).toBe('image');\n expect(classifyModality('gpt-image-1')).toBe('image');\n expect(classifyModality('tts-1')).toBe('audio');\n expect(classifyModality('text-embedding-3-large')).toBe('embedding');\n expect(classifyModality('gpt-5.1-chat-latest')).toBe('text');\n });\n});\n\ndescribe('detectThinking', () => {\n it('flags reasoning model ids', () => {\n expect(detectThinking('o3-mini')).toBe(true);\n expect(detectThinking('deepseek-reasoner')).toBe(true);\n expect(detectThinking('gpt-4o')).toBe(false);\n });\n});\n\ndescribe('normalizeOpenAiModels', () => {\n it('maps an OpenAI /v1/models list to text-only DiscoveredModel[]', () => {\n const raw = { data: [{ id: 'gpt-5.1-chat-latest' }, { id: 'dall-e-3' }, { id: 'tts-1' }] };\n const models = normalizeOpenAiModels(raw);\n expect(models.map(m => m.id)).toEqual(['gpt-5.1-chat-latest']);\n expect(models[0]).toMatchObject({ id: 'gpt-5.1-chat-latest', modality: 'text', supports_thinking: false });\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts` → Expected: FAIL.\n\n- [x] **Step 2: Implement `discovery/normalize.ts`**\n\n```ts\nimport type { DiscoveredModel, ModelModality } from '../types';\n\nconst IMAGE_RE = /(dall-e|gpt-image|imagen|stable-diffusion|flux)/i;\nconst AUDIO_RE = /(tts|whisper|audio|speech|voice)/i;\nconst EMBED_RE = /(embedding|embed|rerank)/i;\nconst THINK_RE = /(^o\\d|reason|thinking|-r1\\b|deepseek-reasoner)/i;\n\nexport function classifyModality(id: string): ModelModality {\n if (IMAGE_RE.test(id)) return 'image';\n if (AUDIO_RE.test(id)) return 'audio';\n if (EMBED_RE.test(id)) return 'embedding';\n return 'text';\n}\n\nexport function detectThinking(id: string): boolean {\n return THINK_RE.test(id);\n}\n\n/** Normalize an OpenAI-compatible `{ data: [{ id }] }` models response to text-only models. */\nexport function normalizeOpenAiModels(raw: unknown): DiscoveredModel[] {\n const data = (raw as { data?: Array<{ id?: string }> })?.data ?? [];\n return data\n .map((m) => m?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n .map((id) => ({ id, modality: classifyModality(id), supports_thinking: detectThinking(id) }))\n .filter((m) => m.modality === 'text');\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Write failing tests for `reconcile.ts`**\n\n`src/modules/ai/__tests__/reconcile.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { reconcileSelection } from '../discovery/reconcile';\n\ndescribe('reconcileSelection', () => {\n const discovered = [{ id: 'a', modality: 'text' as const }, { id: 'b', modality: 'text' as const, deprecated: true }];\n it('marks selected, deprecated, and unavailable', () => {\n const stored = [\n { id: 'a', enabled: true, order: 0, modality: 'text' as const, status: 'available' as const, source: 'discovered' as const },\n { id: 'gone', enabled: true, order: 1, modality: 'text' as const, status: 'available' as const, source: 'discovered' as const },\n ];\n const view = reconcileSelection(stored, discovered);\n expect(view.find(m => m.id === 'a')).toMatchObject({ selected: true, status: 'available' });\n expect(view.find(m => m.id === 'b')).toMatchObject({ selected: false, status: 'deprecated' });\n expect(view.find(m => m.id === 'gone')).toMatchObject({ selected: true, status: 'unavailable' });\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/reconcile.test.ts` → Expected: FAIL.\n\n- [x] **Step 4: Implement `discovery/reconcile.ts`**\n\n```ts\nimport type { DiscoveredModel, ModelSelection, ModelStatus } from '../types';\n\nexport interface ReconciledModel extends DiscoveredModel {\n selected: boolean;\n status: ModelStatus;\n}\n\nexport function reconcileSelection(\n stored: ModelSelection[],\n discovered: DiscoveredModel[],\n): ReconciledModel[] {\n const storedById = new Map(stored.map((m) => [m.id, m]));\n const discoveredById = new Map(discovered.map((m) => [m.id, m]));\n\n const out: ReconciledModel[] = discovered.map((d) => ({\n ...d,\n selected: storedById.has(d.id),\n status: d.deprecated ? 'deprecated' : 'available',\n }));\n\n for (const s of stored) {\n if (!discoveredById.has(s.id)) {\n out.push({ id: s.id, name: s.name, modality: s.modality, supports_thinking: s.supports_thinking, selected: true, status: 'unavailable' });\n }\n }\n return out;\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/reconcile.test.ts` → Expected: PASS.\n\n- [x] **Step 5: Add `listModels` to `IAIProvider` and implement it for OpenAI**\n\nIn `types.ts`, extend the interface:\n```ts\nexport interface ListModelsResult { supported: boolean; models: DiscoveredModel[]; }\nexport interface IAIProvider {\n readonly provider: AIProvider;\n generateContent(request: GenerateContentRequest): Promise;\n validateApiKey(apiKey: string): Promise;\n listModels(apiKey: string): Promise;\n}\n```\n\nIn `openai.provider.ts`, add the method (reuse the existing `/v1/models` fetch):\n```ts\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n// ...\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch('https://api.openai.com/v1/models', {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nAdd the `DiscoveredModel` import to `openai.provider.ts`.\n\n- [x] **Step 6: Run the phase tests**\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts src/modules/ai/__tests__/reconcile.test.ts`\n→ Expected: PASS. (Full `pnpm typecheck` is still red until other providers gain `listModels` in\nPhase 3 — expected.)\n\n- [x] **Step 7: Commit**\n\n```bash\ngit add src/modules/ai/discovery src/modules/ai/__tests__/normalize.test.ts src/modules/ai/__tests__/reconcile.test.ts src/modules/ai/types.ts src/modules/ai/providers/openai.provider.ts\ngit commit -m \"feat(ai): discovery normalizer + obsolescence reconcile + openai listModels\"\n```\n\n---\n\n## Phase 3 — `listModels` for the remaining built-in providers\n\n**Files:**\n- Modify: `src/modules/ai/providers/{gemini,openrouter,mistral,moonshot,xai,anthropic,deepseek,qwen,zhipu}.provider.ts`\n\nFor each provider class, add a `listModels(apiKey)` method using the provider's models endpoint and\nthe shared normalizer where the response is OpenAI-shaped.\n\n- [x] **Step 1: OpenAI-compatible `/v1/models` providers**\n\nFor `openrouter` (`https://openrouter.ai/api/v1/models`), `mistral`\n(`https://api.mistral.ai/v1/models`), `moonshot` (`https://api.moonshot.cn/v1/models`), `xai`\n(`https://api.x.ai/v1/models`), `deepseek` (`https://api.deepseek.com/models`), `anthropic`\n(`https://api.anthropic.com/v1/models`, header `x-api-key` + `anthropic-version: 2023-06-01`), add:\n\n```ts\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n// ...\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch('', { headers: { } });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nUse `Authorization: Bearer ${apiKey}` for all except anthropic (`'x-api-key': apiKey,\n'anthropic-version': '2023-06-01'`). Anthropic and OpenRouter return `{ data: [{ id }] }`, which\n`normalizeOpenAiModels` already handles.\n\n- [x] **Step 2: Gemini (different response shape)**\n\n`gemini.provider.ts` — endpoint `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`\nreturns `{ models: [{ name: 'models/gemini-...' }] }`. Add a small local mapper:\n```ts\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);\n if (!res.ok) return { supported: true, models: [] };\n const raw = await res.json() as { models?: Array<{ name?: string }> };\n const ids = (raw.models ?? []).map(m => (m.name ?? '').replace(/^models\\//, '')).filter(Boolean);\n const models = ids.map(id => ({ id, modality: classifyModality(id), supports_thinking: detectThinking(id) }))\n .filter(m => m.modality === 'text');\n return { supported: true, models };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nImport `classifyModality, detectThinking` from `../discovery/normalize`.\n\n- [x] **Step 3: Providers without a usable list endpoint**\n\nFor `qwen` and `zhipu`, return unsupported so the UI falls back to manual/bulk:\n```ts\nasync listModels(): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n return { supported: false, models: [] };\n}\n```\n(If during implementation a working models endpoint is confirmed for either, implement it like\nStep 1 instead. Default to `supported: false` when unverified.)\n\n- [x] **Step 4: Verify every provider satisfies `IAIProvider`**\n\nRun: `pnpm typecheck 2>&1 | rg \"provider\" | head`\nExpected: no \"Property 'listModels' is missing\" errors for any provider class. (Other AI files may\nstill be red until Phase 7.)\n\n- [x] **Step 5: Commit**\n\n```bash\ngit add src/modules/ai/providers\ngit commit -m \"feat(ai): listModels for all built-in providers (qwen/zhipu fall back to manual)\"\n```\n\n---\n\n## Phase 4 — custom OpenAI-compatible providers\n\n**Files:**\n- Create: `src/modules/ai/providers/openai-compatible.provider.ts`\n- Modify: `src/modules/ai/ai.service.ts` (factory: default branch → custom provider)\n- Create: `src/pages/api/admin/ai/custom-providers/index.ts` (GET list, POST create)\n- Create: `src/pages/api/admin/ai/custom-providers/[id].ts` (PUT update, DELETE)\n- Modify: `src/shared/validation/schemas/ai.ts` (custom provider schemas)\n- Create: `src/modules/ai/__tests__/openai-compatible.test.ts`\n\n- [x] **Step 1: Failing test for the generic provider's normalization**\n\n`src/modules/ai/__tests__/openai-compatible.test.ts`:\n```ts\nimport { describe, it, expect, vi, afterEach } from 'vitest';\nimport { OpenAICompatibleProvider } from '../providers/openai-compatible.provider';\n\nafterEach(() => vi.restoreAllMocks());\n\ndescribe('OpenAICompatibleProvider.listModels', () => {\n it('lists text models from a custom base_url', async () => {\n vi.spyOn(globalThis, 'fetch').mockResolvedValue(\n new Response(JSON.stringify({ data: [{ id: 'custom-chat' }, { id: 'tts-x' }] }), { status: 200 }),\n );\n const p = new OpenAICompatibleProvider('my-endpoint', 'https://api.example.com/v1', 'k');\n const out = await p.listModels('k');\n expect(out.supported).toBe(true);\n expect(out.models.map(m => m.id)).toEqual(['custom-chat']);\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts` → Expected: FAIL.\n\n- [x] **Step 2: Implement `openai-compatible.provider.ts`**\n\n```ts\nimport type { IAIProvider, GenerateContentRequest, GenerateContentResponse, DiscoveredModel } from '../types';\nimport { getSystemPrompt } from '../prompts';\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n\nexport class OpenAICompatibleProvider implements IAIProvider {\n readonly provider: string;\n constructor(provider: string, private baseUrl: string, private apiKey: string) {\n this.provider = provider;\n }\n private url(path: string) { return `${this.baseUrl.replace(/\\/$/, '')}${path}`; }\n\n async generateContent(request: GenerateContentRequest): Promise {\n const systemPrompt = getSystemPrompt(request.content_type, request.system_prompt);\n try {\n const res = await fetch(this.url('/chat/completions'), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` },\n body: JSON.stringify({\n model: request.model,\n messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: request.prompt }],\n temperature: request.temperature ?? 0.7,\n response_format: { type: 'json_object' },\n }),\n });\n const data = await res.json() as { choices?: Array<{ message?: { content?: string } }>; error?: { message?: string } };\n if (data.error) return { success: false, error: data.error.message || 'Provider error' };\n const text = data.choices?.[0]?.message?.content;\n if (!text) return { success: false, error: 'No content generated' };\n return { success: true, data: JSON.parse(text) };\n } catch (e) {\n return { success: false, error: `Generation failed: ${e instanceof Error ? e.message : 'Unknown error'}` };\n }\n }\n\n async validateApiKey(apiKey: string): Promise {\n try { return (await fetch(this.url('/models'), { headers: { Authorization: `Bearer ${apiKey}` } })).ok; }\n catch { return false; }\n }\n\n async listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch(this.url('/models'), { headers: { Authorization: `Bearer ${apiKey}` } });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch { return { supported: true, models: [] }; }\n }\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Open the factory to custom providers (`ai.service.ts`)**\n\nChange `createProvider` to accept an optional base_url and route unknown ids to the generic class:\n```ts\nexport function createProvider(provider: AIProvider, apiKey: string, baseUrl?: string): IAIProvider {\n switch (provider) {\n case 'gemini': return new GeminiProvider(apiKey);\n case 'openai': return new OpenAIProvider(apiKey);\n case 'anthropic': return new AnthropicProvider(apiKey);\n case 'deepseek': return new DeepSeekProvider(apiKey);\n case 'openrouter': return new OpenRouterProvider(apiKey);\n case 'qwen': return new QwenProvider(apiKey);\n case 'zhipu': return new ZhipuProvider(apiKey);\n case 'moonshot': return new MoonshotProvider(apiKey);\n case 'mistral': return new MistralProvider(apiKey);\n case 'xai': return new XAIProvider(apiKey);\n default:\n if (!baseUrl) throw new Error(`Unknown provider: ${provider}`);\n return new OpenAICompatibleProvider(provider, baseUrl, apiKey);\n }\n}\n```\nImport `OpenAICompatibleProvider`.\n\n- [x] **Step 4: Custom-provider Zod schemas (`ai.ts`)**\n\nAdd to `src/shared/validation/schemas/ai.ts`:\n```ts\nexport const CreateCustomProviderSchema = z.object({\n id: z.string().min(1).regex(/^[a-z0-9-]+$/, 'id must be kebab-case'),\n label: z.string().min(1),\n base_url: z.string().url(),\n api_key: z.string().min(1),\n enabled: z.boolean().optional(),\n});\nexport const UpdateCustomProviderSchema = z.object({\n label: z.string().min(1).optional(),\n base_url: z.string().url().optional(),\n api_key: z.string().optional(),\n enabled: z.boolean().optional(),\n});\n```\n\n- [x] **Step 5: Custom-provider CRUD routes**\n\n`src/pages/api/admin/ai/custom-providers/index.ts` — `GET` returns\n`stripApiKeys(settings).custom_providers`; `POST` validates `CreateCustomProviderSchema` then\n`saveAiSettings(db, { custom_providers: { [id]: { label, base_url, api_key, enabled: enabled ?? true, models: [] } } })`.\n`src/pages/api/admin/ai/custom-providers/[id].ts` — `PUT` validates `UpdateCustomProviderSchema`\nthen `saveAiSettings(db, { custom_providers: { [id]: patch } })` (deep merge keeps key/models);\n`DELETE` loads settings, removes the id from `custom_providers`, writes the full object via a\ndedicated `replaceAiSettings` (full overwrite) — add `replaceAiSettings(db, settings)` to\n`settings-store.ts` that validates + writes without merge. Both require `AuthRoles.ADMIN` via\n`extractAuthContext`/`hasRole` (follow `migrate-models.ts` auth pattern).\n\n- [x] **Step 6: Verify + commit**\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts`\n→ Expected: PASS.\n```bash\ngit add src/modules/ai/providers/openai-compatible.provider.ts src/modules/ai/ai.service.ts src/modules/ai/settings-store.ts src/shared/validation/schemas/ai.ts src/pages/api/admin/ai/custom-providers src/modules/ai/__tests__/openai-compatible.test.ts\ngit commit -m \"feat(ai): OpenAI-compatible custom providers + CRUD\"\n```\n\n---\n\n## Phase 5 — discovery + settings + providers + generate endpoints (snake_case)\n\n**Files:**\n- Create: `src/pages/api/admin/ai/models/[provider]/discover.ts`\n- Modify: `src/pages/api/admin/ai/settings.ts`, `providers.ts`, `generate.ts`,\n `models/[provider].ts`, `models/[provider]/[modelId].ts`\n- Delete: `src/pages/api/admin/ai/migrate-models.ts`\n- Modify: `src/shared/validation/schemas/ai.ts` (snake_case settings/generate schemas)\n- Modify: `src/modules/ai/ai.service.ts` (`getModelsForProvider`, `generateContent`,\n `getConfiguredProviders`, resolution helper)\n\n- [x] **Step 1: Rewrite `ai.ts` request schemas to snake_case**\n\nUpdate `GenerateSchema`, `UpdateSettingsSchema`, `AddModelSchema`, `UpdateModelSchema`,\n`ProviderModelParam`, `ValidateApiKeySchema` to snake_case keys:\n```ts\nexport const GenerateSchema = z.object({\n prompt: z.string().min(3).max(10000),\n content_type: z.enum(['recipe', 'article', 'roundup']),\n provider: z.string().optional(),\n model: z.string().min(1).optional(),\n temperature: z.number().min(0).max(2).optional(),\n reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),\n}).passthrough();\n\nexport const UpdateSettingsSchema = z.object({\n default_provider: z.string().optional(),\n default_model: z.string().optional(),\n temperature: z.number().min(0).max(1).optional(),\n system_prompt: z.string().optional(),\n providers: z.record(z.string(), z.object({\n api_key: z.string().optional(),\n enabled: z.boolean().optional(),\n models: z.array(z.any()).optional(),\n })).optional(),\n}).passthrough();\n\nexport const AddModelSchema = z.object({\n id: z.string().min(1),\n name: z.string().min(1),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n}).passthrough();\n\nexport const UpdateModelSchema = z.object({\n name: z.string().min(1).optional(),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n deprecated: z.boolean().optional(),\n enabled: z.boolean().optional(),\n}).passthrough();\n\nexport const ProviderModelParam = z.object({ provider: z.string().min(1), model_id: z.string().min(1) });\nexport const ValidateApiKeySchema = z.object({ provider: z.string().min(1), api_key: z.string().min(1) });\n```\n(`ProviderEnum` is replaced by `z.string()` since custom providers exist; validity is checked\nagainst settings at runtime.)\n\n- [x] **Step 2: Discovery endpoint**\n\n`src/pages/api/admin/ai/models/[provider]/discover.ts` (`GET`, `AuthRoles.ADMIN`):\n```ts\n// resolve provider config (built-in providers[p] or custom_providers[p]) from getAiSettings\n// const cfg = settings.providers[provider] ?? settings.custom_providers[provider];\n// if (!cfg?.api_key) -> 400 \"No API key configured for \"\n// const impl = createProvider(provider, cfg.api_key, (cfg as CustomProviderConfig).base_url);\n// const { supported, models } = await impl.listModels(cfg.api_key);\n// const view = reconcileSelection(cfg.models ?? [], models);\n// return formatSuccessResponse({ supported, models: view });\n```\nUse `formatSuccessResponse`/`formatErrorResponse` and the `validateParams`-style param read used by\nsibling routes.\n\n- [x] **Step 3: `settings.ts` GET (masked) / PUT (deep merge)**\n\nGET: `formatSuccessResponse(stripApiKeys(await getAiSettings(db)))`.\nPUT: `validateBody(request, UpdateSettingsSchema)` → `saveAiSettings(db, body)` → return\n`stripApiKeys(await getAiSettings(db))`.\nPOST (validate key): `createProvider(provider, api_key, base_url?).validateApiKey(api_key)`.\nRemove every camelCase field; require `AuthRoles.ADMIN`.\n\n- [x] **Step 4: `providers.ts`, `generate.ts`, model CRUD to snake_case + selection**\n\n`providers.ts` GET → `{ configured_providers, available_models, provider_info, defaults }` where\n`available_models[p] = getModelsForProvider(db, p)` (stored selection, enabled only). Strip keys.\n`generate.ts` → read `GenerateSchema`, call `generateContent(db, { ...body })`, snake_case response.\n`models/[provider].ts` (POST add / GET list) and `models/[provider]/[modelId].ts` (PUT/DELETE) →\noperate on `providers[p].models` (or `custom_providers[p].models`) via `saveAiSettings`, snake_case\nfields, `source: 'manual'` for manually added models.\n\n- [x] **Step 5: `ai.service.ts` resolution + generate**\n\n```ts\nexport async function getModelsForProvider(db: D1Database, provider: AIProvider): Promise {\n const settings = await getAiSettings(db);\n const cfg = settings.providers[provider as BuiltInProvider] ?? settings.custom_providers[provider];\n return (cfg?.models ?? []).filter(m => m.enabled && m.status !== 'unavailable');\n}\n\nexport async function generateContent(db: D1Database, request: GenerateContentRequest): Promise {\n const settings = await getAiSettings(db);\n const provider = request.provider || settings.default_provider;\n const cfg = settings.providers[provider as BuiltInProvider] ?? settings.custom_providers[provider];\n if (!cfg?.api_key) return { success: false, error: `No API key configured for provider: ${provider}` };\n if (!cfg.enabled) return { success: false, error: `Provider is disabled: ${provider}` };\n const impl = createProvider(provider, cfg.api_key, (cfg as CustomProviderConfig).base_url);\n const model = request.model || (provider === settings.default_provider ? settings.default_model : request.model);\n return impl.generateContent({\n ...request,\n model: model || request.model,\n temperature: request.temperature ?? settings.temperature,\n system_prompt: request.system_prompt || settings.system_prompt,\n });\n}\n```\nUpdate `getConfiguredProviders` to iterate `providers` + `custom_providers` reading `api_key`/`enabled`.\n\n- [x] **Step 6: Delete the obsolete migration endpoint**\n\n```bash\ngit rm src/pages/api/admin/ai/migrate-models.ts\n```\nThen `rg -n \"migrate-models|AVAILABLE_MODELS\" src` and remove every remaining reference.\n\n- [x] **Step 7: Verify + commit**\n\nRun: `pnpm test && pnpm check:boundaries`\nExpected: PASS / \"Boundary check passed.\" (`pnpm typecheck` may still be red until admin UI in\nPhase 7.)\n```bash\ngit add -A\ngit commit -m \"feat(ai): discovery + snake_case settings/providers/generate endpoints; drop migrate-models\"\n```\n\n---\n\n## Phase 6 — thinking / reasoning mapping at the provider boundary\n\n**Files:**\n- Modify: providers that support reasoning — `openai`, `anthropic`, `deepseek`, `openai-compatible`\n- Modify: `src/modules/ai/__tests__/...` (one mapping test)\n\n- [x] **Step 1: Map `reasoning_effort` per provider at the fetch boundary**\n\nIn `openai.provider.ts` and `openai-compatible.provider.ts`, when `request.reasoning_effort` is set,\nadd `reasoning_effort: request.reasoning_effort` to the request body. In `anthropic.provider.ts`,\nmap to `thinking: { type: 'enabled', budget_tokens: }`. In\n`deepseek.provider.ts`, no body change is required (reasoner is model-selected) — leave a comment.\nProviders without support ignore the field (no change).\n\n- [x] **Step 2: Test the mapping (openai body includes reasoning_effort)**\n\nAdd to an existing/new provider test that mocks `fetch` and asserts the request body passed to\n`fetch` contains `reasoning_effort: 'high'` when set. Run that test → Expected: PASS.\n\n- [x] **Step 3: Commit**\n\n```bash\ngit add src/modules/ai/providers src/modules/ai/__tests__\ngit commit -m \"feat(ai): map reasoning_effort to provider-native thinking params\"\n```\n\n---\n\n## Phase 7 — admin UI: snake_case + discovery UX + has_api_key\n\n**Files:**\n- Modify: `src/admin/services/api.ts` (AI client methods)\n- Modify: `src/admin/components/ModelManager.tsx`, `BulkImportModels.tsx`\n- Modify: `src/admin/features/settings/pages/tabs/AISettings.tsx`,\n `src/admin/components/BlockEditor/components/AISettings.tsx`,\n `src/admin/features/settings/pages/Settings.tsx`\n\n- [x] **Step 1: `api.ts` AI client methods → snake_case + discover + custom providers**\n\nAdd/repoint methods: `aiSettings.get/update`, `aiProviders.get`, `aiModels.discover(provider)`,\n`aiModels.add/update/remove`, `aiCustomProviders.list/create/update/remove`, `aiGenerate(payload)`.\nAll payloads/reads use snake_case (`content_type`, `reasoning_effort`, `default_provider`,\n`has_api_key`, `context_window`, `max_tokens`).\n\n- [x] **Step 2: ModelManager — discovery checklist**\n\nRework `ManagedModel` to snake_case (`context_window`, `max_tokens`, `enabled`, `deprecated`,\n`status`). Add a \"Fetch latest models\" button calling `aiModels.discover(provider)`; render the\nreturned `models` as a checklist with `status` badges (`unavailable`/`deprecated`); ticking a model\nsaves it into the selection via `aiSettings.update`/`aiModels.add`. Keep manual add + bulk import\nvisible as fallback (and shown automatically when `discover` returns `supported: false`).\n\n- [x] **Step 3: BulkImportModels + both AISettings tabs + Settings.tsx**\n\nRename all AI data-key reads/writes to snake_case (`context_window`, `max_tokens`, `api_key`→write\nonly, render `has_api_key`/`api_key_masked` instead of the raw key, `default_provider`,\n`default_model`, `system_prompt`, `reasoning_effort` selector shown when the chosen model has\n`supports_thinking`). Add a custom-provider add/edit form (id/label/base_url/api_key) backed by\n`aiCustomProviders`.\n\n- [x] **Step 4: Verify full green**\n\nRun: `pnpm typecheck && pnpm test && pnpm check:boundaries`\nExpected: all green (this is the phase where typecheck returns fully clean).\n\n- [x] **Step 5: Commit**\n\n```bash\ngit add src/admin\ngit commit -m \"feat(ai): snake_case admin AI UI + model discovery UX + write-only keys\"\n```\n\n---\n\n## Phase 8 — one-shot stored-blob data migration\n\n**Files:**\n- Create: `scripts/migrate-ai-settings.mts`\n- Create: `src/modules/ai/__tests__/migrate-ai-settings.test.ts`\n\n- [x] **Step 1: Failing test for the pure transform**\n\n`src/modules/ai/__tests__/migrate-ai-settings.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { migrateAiSettingsBlob } from '../../../scripts/migrate-ai-settings.mts';\n\ndescribe('migrateAiSettingsBlob', () => {\n it('converts legacy camelCase blob to snake_case selection shape', () => {\n const legacy = {\n defaultProvider: 'openai', defaultModel: 'gpt-4o', temperature: 0.7, systemPrompt: 'sp',\n providers: { openai: { apiKey: 'sk-1', enabled: true,\n availableModels: [{ id: 'gpt-4o', name: 'GPT-4o', contextWindow: 131072, maxTokens: 65536, enabled: true }] } },\n };\n const out = migrateAiSettingsBlob(legacy);\n expect(out.default_provider).toBe('openai');\n expect(out.system_prompt).toBe('sp');\n expect(out.providers.openai.api_key).toBe('sk-1');\n expect(out.providers.openai.models[0]).toMatchObject({\n id: 'gpt-4o', context_window: 131072, max_tokens: 65536, modality: 'text',\n status: 'available', source: 'discovered', enabled: true,\n });\n expect(out.custom_providers).toEqual({});\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/migrate-ai-settings.test.ts` → Expected: FAIL.\n\n- [x] **Step 2: Implement `scripts/migrate-ai-settings.mts`**\n\nExport a pure `migrateAiSettingsBlob(legacy)` (camelCase → snake_case, `availableModels` → `models`\nwith `modality:'text'`, `status:'available'`, `source:'discovered'`, `order` by index) plus a\n`main()` that opens the local D1 sqlite (reuse the `findDb` pattern from\n`scripts/migrate-credit-avatar-r2key.mts`), reads `site_settings.value WHERE key='ai_settings'`,\nprints a dry-run diff, and writes the transformed blob under `--apply`. Validate the result with\n`AiSettingsSchema` before writing.\n\nRun: `pnpm test src/modules/ai/__tests__/migrate-ai-settings.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Dry-run against local D1**\n\nRun: `node_modules/.bin/tsx scripts/migrate-ai-settings.mts`\nExpected: prints the would-be snake_case blob; no write. (If no `ai_settings` row exists locally,\nit prints \"no ai_settings row — nothing to migrate\".)\n\n- [x] **Step 4: Commit**\n\n```bash\ngit add scripts/migrate-ai-settings.mts src/modules/ai/__tests__/migrate-ai-settings.test.ts\ngit commit -m \"chore(ai): one-shot migration of stored ai_settings to snake_case selection shape\"\n```\n\n---\n\n## Phase 9 — finalize & close AI portion of #3\n\n**Files:**\n- Modify: `docs/NAMING_CONTRACT.md` (only if it still implies AI is pending)\n- Modify: `.hermes/plans/2026-06-03_contract-audit-report.md` (local record)\n\n- [x] **Step 1: Full regression gate**\n\nRun: `pnpm typecheck && pnpm test && pnpm check:boundaries`\nExpected: all green.\n\n- [x] **Step 2: Contract audit — no AI camelCase regressions**\n\nRun: `rg -n \"\\b(defaultProvider|defaultModel|systemPrompt|apiKey|availableModels|contextWindow|maxTokens|modelId)\\b\" src/modules/ai src/pages/api/admin/ai src/admin --glob '!**/*.test.*'`\nExpected: matches only inside provider classes at the external fetch boundary (allowed exception)\nand local variables — no app-owned data-shape keys. Then\n`node scripts/local-contract-audit.mjs --summary` → no new camelCase violation.\n\n- [x] **Step 3: Commit (if docs changed)**\n\n```bash\ngit add docs/NAMING_CONTRACT.md\ngit commit -m \"docs(naming): AI subsystem migrated to snake_case (audit #3)\"\n```\n\n---\n\n## Self-review notes\n\n- **Spec coverage:** stored shape (P0/P1/P5), discovery `listModels` (P2/P3), discover endpoint\n (P5), custom providers (P4), safe persistence + Zod (P1), key masking (P1/P5), obsolescence\n reconcile (P2 + P5 resolution filter), thinking (P6), modality filter (P2 normalizer), snake_case\n E2E (P0/P5/P7), data migration (P8), cleanup of AVAILABLE_MODELS + migrate-models (P5). ✓\n- **No placeholders:** pure-logic units ship full code + tests; mechanical endpoint/UI tasks give\n exact files, exact snake_case key lists, and a shared normalizer (DRY) rather than repeated\n provider code. ✓\n- **Type consistency:** `AiSettings`, `ModelSelection`, `DiscoveredModel`, `ListModelsResult`,\n `reconcileSelection`, `mergeAiSettings`, `getAiSettings`/`saveAiSettings`/`replaceAiSettings`,\n `stripApiKeys`/`maskApiKey`, `normalizeOpenAiModels`/`classifyModality`/`detectThinking` are used\n with consistent names across phases. ✓\n- **Typecheck phasing:** Phases 1, 2, 3, 8 are green in isolation; Phases 0, 4–7 may leave global\n typecheck red until Phase 7 (documented in the Phase 0 note); every phase's own unit tests pass.\n"}, "c6cc679f8ae839fb6d9965a21972852d3fbe5813": {"t": 1785765909.2071245, "n": 47698, "h": "8e72655065098b35", "c": "# AI Model Management Redesign Implementation Plan\n\n> **Status: COMPLETED** (all tasks done; module shipped with tests).\n\n> **STATUS — 2026-06-04: ✅ COMPLETED, REVIEWED & FIXED.** All phases implemented (snake_case types/store/discovery/providers/custom providers/endpoints/reasoning/admin UI + data migration). Code review fixes applied (`a71be82`: anthropic max_tokens>budget, provider-id collision guard, un-deprecate, editor reasoning_effort). Stored `ai_settings` blob migrated locally (`migrate-ai-settings --apply`). Verified: typecheck 0 · tests green · boundaries ✅. Ops: run `migrate-ai-settings --apply` on prod D1 at deploy.\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Replace the hardcoded AI model catalog with live per-provider discovery, store only the admin's curated snake_case selection with safe validated persistence and write-only keys, add OpenAI-compatible custom providers, model-obsolescence reconciliation, and a thinking/reasoning control — closing the AI portion of Contract Audit #3.\n\n**Architecture:** Pure-logic units (snake_case types, Zod stored-blob schema, deep-merge persistence, key masking, model normalization/modality classification, obsolescence reconciliation) are built test-first. Provider `listModels` implementations share one OpenAI-style normalizer. API routes, the admin UI, and the client are migrated to snake_case and wired to discovery. A one-shot script migrates the existing stored blob.\n\n**Tech Stack:** Cloudflare D1 (`site_settings`), Zod, Astro API routes, React admin SPA, Vitest, `node:sqlite` for the migration script.\n\n**Spec:** `docs/superpowers/specs/2026-06-04-ai-model-management-redesign-design.md`\n\n---\n\n## Conventions (read once)\n\n- **snake_case** for all app-owned data keys (stored blob, our API envelopes, admin props, types\n that model them). Local variables/functions/React state may stay camelCase (`NAMING_CONTRACT`\n L21). External provider HTTP request/response field names stay as the provider names **only\n inside each provider class** at the fetch boundary (Allowed Exception).\n- **TDD** for pure units (types are compile-time; logic units get a failing test first). Mechanical\n snake_case renames and UI wiring are verified by `pnpm test && pnpm check:boundaries &&\n pnpm typecheck`.\n- **Run between every task:** `pnpm test && pnpm check:boundaries && pnpm typecheck`. All must be\n green before the task's commit.\n- Test files live next to the code under `__tests__/` per existing convention\n (e.g. `src/modules/ai/__tests__/...`).\n\n## File structure (created / modified)\n\n- `src/modules/ai/types.ts` — replace AISettings/AIModel/AVAILABLE_MODELS with snake_case\n `AiSettings`, `ModelSelection`, `DiscoveredModel`, capability enums; keep `ALL_PROVIDERS`,\n `PROVIDER_INFO`, `DEFAULT_AI_SETTINGS` (snake).\n- `src/modules/ai/settings-schema.ts` (new) — Zod schema for the stored blob.\n- `src/modules/ai/settings-store.ts` (new) — `getAiSettings` / `saveAiSettings` (deep merge +\n validate) extracted from `ai.service.ts`.\n- `src/modules/ai/api-key.ts` (new) — `maskApiKey`, `stripApiKeys`.\n- `src/modules/ai/discovery/normalize.ts` (new) — `normalizeOpenAiModels`, `classifyModality`,\n `detectThinking`.\n- `src/modules/ai/discovery/reconcile.ts` (new) — `reconcileSelection`.\n- `src/modules/ai/providers/openai-compatible.provider.ts` (new) — generic custom provider.\n- `src/modules/ai/providers/*.provider.ts` — add `listModels`; snake_case app-owned fields.\n- `src/modules/ai/ai.service.ts` — discovery resolution, generate with `reasoning_effort`,\n open-ended factory.\n- `src/shared/validation/schemas/ai.ts` — snake_case Zod schemas + `reasoning_effort`.\n- `src/pages/api/admin/ai/models/[provider]/discover.ts` (new) — discovery endpoint.\n- `src/pages/api/admin/ai/settings.ts`, `providers.ts`, `generate.ts`,\n `models/[provider]/[modelId].ts`, `models/[provider].ts` — snake_case + masking + custom CRUD.\n- `src/pages/api/admin/ai/migrate-models.ts` — **delete**.\n- `src/pages/api/admin/ai/custom-providers/*` (new) — custom provider CRUD.\n- `src/admin/components/ModelManager.tsx`, `BulkImportModels.tsx`,\n `src/admin/features/settings/pages/tabs/AISettings.tsx`,\n `src/admin/components/BlockEditor/components/AISettings.tsx`,\n `src/admin/features/settings/pages/Settings.tsx`, `src/admin/services/api.ts` — snake_case +\n discovery UX + `has_api_key`.\n- `scripts/migrate-ai-settings.mts` (new) — one-shot stored-blob migration.\n\n---\n\n## Phase 0 — snake_case types & defaults\n\n**Files:**\n- Modify: `src/modules/ai/types.ts`\n\n- [x] **Step 1: Replace the data-shape interfaces with snake_case**\n\nReplace `AIModel`, `AISettings`, `DEFAULT_AI_SETTINGS`, and remove `AVAILABLE_MODELS`. Keep\n`AIProvider`, `ALL_PROVIDERS`, `PROVIDER_INFO`, `IAIProvider`, `GenerateContentRequest`,\n`GeneratedRecipe`. New definitions:\n\n```ts\n/** Built-in provider id, or a custom OpenAI-compatible provider id (slug). */\nexport type BuiltInProvider =\n | 'gemini' | 'openai' | 'anthropic' | 'deepseek' | 'openrouter'\n | 'qwen' | 'zhipu' | 'moonshot' | 'mistral' | 'xai';\nexport type AIProvider = BuiltInProvider | (string & {});\n\nexport type ModelModality = 'text' | 'image' | 'audio' | 'embedding' | 'other';\nexport type ModelStatus = 'available' | 'unavailable' | 'deprecated';\n\n/** A model the provider currently offers (discovery output). */\nexport interface DiscoveredModel {\n id: string;\n name?: string;\n context_window?: number;\n max_tokens?: number;\n modality: ModelModality;\n supports_thinking?: boolean;\n deprecated?: boolean;\n}\n\n/** A model the admin has curated into the stored selection. */\nexport interface ModelSelection {\n id: string;\n name?: string;\n context_window?: number;\n max_tokens?: number;\n modality: ModelModality;\n supports_thinking?: boolean;\n enabled: boolean;\n order: number;\n deprecated?: boolean;\n status: ModelStatus;\n source: 'discovered' | 'manual';\n}\n\nexport interface ProviderConfig {\n enabled: boolean;\n api_key: string;\n models: ModelSelection[];\n}\n\nexport interface CustomProviderConfig extends ProviderConfig {\n label: string;\n base_url: string;\n}\n\nexport interface AiSettings {\n default_provider: AIProvider;\n default_model: string;\n temperature: number;\n system_prompt: string;\n providers: Partial>;\n custom_providers: Record;\n}\n\nexport const DEFAULT_AI_SETTINGS: AiSettings = {\n default_provider: 'gemini',\n default_model: 'gemini-3-flash-preview',\n temperature: 0.7,\n system_prompt: `You are an expert culinary content creator. Generate structured recipe and article content in JSON format.\nAlways respond with valid JSON that matches the expected schema.\nBe creative, detailed, and provide practical cooking tips.`,\n providers: {},\n custom_providers: {},\n};\n```\n\nUpdate `GenerateContentRequest` to snake_case app-owned fields and add the reasoning control:\n\n```ts\nexport interface GenerateContentRequest {\n prompt: string;\n content_type: 'recipe' | 'article' | 'roundup';\n provider: AIProvider;\n model: string;\n temperature?: number;\n system_prompt?: string;\n reasoning_effort?: 'low' | 'medium' | 'high';\n}\n```\n\n- [x] **Step 2: Typecheck (expect downstream breakages — they are fixed in later phases)**\n\nRun: `pnpm typecheck 2>&1 | head -40`\nExpected: errors only in AI files that consume the old names (ai.service, providers, endpoints,\nadmin). These are addressed in Phases 1–7. Do NOT fix unrelated files here.\n\n- [x] **Step 3: Commit**\n\n```bash\ngit add src/modules/ai/types.ts\ngit commit -m \"refactor(ai): snake_case AiSettings + add discovery/selection model types\"\n```\n\n> Note for the executor: Phases 0–7 form one compile-coherent unit. Commit per phase, but\n> `pnpm typecheck` only returns fully green at the end of Phase 7. Phases that CAN be green in\n> isolation (1, 2, 3, 8) state so; Phases 0, 4–7 may leave typecheck red until Phase 7. Each\n> phase's own unit tests must pass.\n\n---\n\n## Phase 1 — safe persistence, validation, key masking\n\n**Files:**\n- Create: `src/modules/ai/settings-schema.ts`\n- Create: `src/modules/ai/api-key.ts`\n- Create: `src/modules/ai/settings-store.ts`\n- Create: `src/modules/ai/__tests__/settings-store.test.ts`\n- Create: `src/modules/ai/__tests__/api-key.test.ts`\n\n- [x] **Step 1: Write failing tests for api-key masking**\n\n`src/modules/ai/__tests__/api-key.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { maskApiKey, stripApiKeys } from '../api-key';\n\ndescribe('maskApiKey', () => {\n it('masks all but the last 4 chars', () => {\n expect(maskApiKey('sk-abcdef1234')).toBe('••••1234');\n });\n it('returns empty string for empty/undefined', () => {\n expect(maskApiKey('')).toBe('');\n expect(maskApiKey(undefined)).toBe('');\n });\n});\n\ndescribe('stripApiKeys', () => {\n it('replaces api_key with has_api_key + masked across providers and custom_providers', () => {\n const out = stripApiKeys({\n default_provider: 'openai', default_model: 'm', temperature: 0.7, system_prompt: 's',\n providers: { openai: { enabled: true, api_key: 'sk-xxxx9999', models: [] } },\n custom_providers: { c1: { label: 'C', base_url: 'u', enabled: true, api_key: 'k-1111', models: [] } },\n });\n expect(out.providers.openai).toMatchObject({ has_api_key: true, api_key_masked: '••••9999' });\n expect('api_key' in out.providers.openai).toBe(false);\n expect(out.custom_providers.c1).toMatchObject({ has_api_key: true, api_key_masked: '••••1111' });\n expect('api_key' in out.custom_providers.c1).toBe(false);\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/api-key.test.ts` → Expected: FAIL (module missing).\n\n- [x] **Step 2: Implement `api-key.ts`**\n\n```ts\nimport type { AiSettings } from './types';\n\nexport function maskApiKey(key: string | undefined): string {\n if (!key) return '';\n return `••••${key.slice(-4)}`;\n}\n\ntype StrippedProvider = { enabled: boolean; models: unknown[]; has_api_key: boolean; api_key_masked: string };\n\nfunction strip(cfg: T): Omit & { has_api_key: boolean; api_key_masked: string } {\n const { api_key, ...rest } = cfg;\n return { ...rest, has_api_key: Boolean(api_key), api_key_masked: maskApiKey(api_key) };\n}\n\nexport function stripApiKeys(settings: AiSettings) {\n return {\n ...settings,\n providers: Object.fromEntries(\n Object.entries(settings.providers).map(([k, v]) => [k, strip(v!)]),\n ),\n custom_providers: Object.fromEntries(\n Object.entries(settings.custom_providers).map(([k, v]) => [k, strip(v)]),\n ),\n };\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/api-key.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Implement the Zod stored-blob schema (`settings-schema.ts`)**\n\n```ts\nimport { z } from 'zod';\n\nconst ModelSelectionSchema = z.object({\n id: z.string().min(1),\n name: z.string().optional(),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n modality: z.enum(['text', 'image', 'audio', 'embedding', 'other']).default('text'),\n supports_thinking: z.boolean().optional(),\n enabled: z.boolean().default(true),\n order: z.number().int().nonnegative().default(0),\n deprecated: z.boolean().optional(),\n status: z.enum(['available', 'unavailable', 'deprecated']).default('available'),\n source: z.enum(['discovered', 'manual']).default('discovered'),\n});\n\nconst ProviderConfigSchema = z.object({\n enabled: z.boolean().default(false),\n api_key: z.string().default(''),\n models: z.array(ModelSelectionSchema).default([]),\n});\n\nconst CustomProviderConfigSchema = ProviderConfigSchema.extend({\n label: z.string().min(1),\n base_url: z.string().url(),\n});\n\nexport const AiSettingsSchema = z.object({\n default_provider: z.string().min(1),\n default_model: z.string().default(''),\n temperature: z.number().min(0).max(2).default(0.7),\n system_prompt: z.string().default(''),\n providers: z.record(z.string(), ProviderConfigSchema).default({}),\n custom_providers: z.record(z.string(), CustomProviderConfigSchema).default({}),\n});\n```\n\n- [x] **Step 4: Write failing tests for the store (deep merge + validate + fallback)**\n\n`src/modules/ai/__tests__/settings-store.test.ts`:\n```ts\nimport { describe, it, expect, vi } from 'vitest';\nimport { mergeAiSettings } from '../settings-store';\nimport { DEFAULT_AI_SETTINGS } from '../types';\n\ndescribe('mergeAiSettings', () => {\n it('deep-merges one provider without dropping others', () => {\n const current = {\n ...DEFAULT_AI_SETTINGS,\n providers: {\n openai: { enabled: true, api_key: 'sk-1', models: [] },\n gemini: { enabled: true, api_key: 'g-1', models: [] },\n },\n };\n const merged = mergeAiSettings(current, { providers: { openai: { enabled: false } } });\n expect(merged.providers.gemini).toEqual({ enabled: true, api_key: 'g-1', models: [] });\n expect(merged.providers.openai).toMatchObject({ enabled: false, api_key: 'sk-1' });\n });\n\n it('keeps existing api_key when the patch omits it', () => {\n const current = { ...DEFAULT_AI_SETTINGS, providers: { openai: { enabled: true, api_key: 'sk-keep', models: [] } } };\n const merged = mergeAiSettings(current, { providers: { openai: { models: [] } } });\n expect(merged.providers.openai.api_key).toBe('sk-keep');\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/settings-store.test.ts` → Expected: FAIL.\n\n- [x] **Step 5: Implement `settings-store.ts`**\n\n```ts\nimport type { D1Database } from '@cloudflare/workers-types';\nimport type { AiSettings } from './types';\nimport { DEFAULT_AI_SETTINGS } from './types';\nimport { AiSettingsSchema } from './settings-schema';\n\nconst AI_SETTINGS_KEY = 'ai_settings';\n\nfunction mergeProviderMap>(current: T, patch: Partial | undefined): T {\n if (!patch) return current;\n const out: Record = { ...current };\n for (const [id, cfg] of Object.entries(patch)) {\n out[id] = { ...(current[id] ?? {}), ...(cfg as object) };\n }\n return out as T;\n}\n\nexport function mergeAiSettings(current: AiSettings, patch: Partial): AiSettings {\n return {\n ...current,\n ...patch,\n providers: mergeProviderMap(current.providers, patch.providers),\n custom_providers: mergeProviderMap(current.custom_providers, patch.custom_providers),\n };\n}\n\nexport async function getAiSettings(db: D1Database): Promise {\n try {\n const row = await db.prepare('SELECT value FROM site_settings WHERE key = ?')\n .bind(AI_SETTINGS_KEY).first<{ value: string }>();\n if (row?.value) {\n const parsed = AiSettingsSchema.safeParse(JSON.parse(row.value));\n if (parsed.success) return parsed.data as AiSettings;\n console.error('Invalid ai_settings blob, using defaults:', parsed.error.issues);\n }\n } catch (e) {\n console.error('Failed to load AI settings:', e);\n }\n return DEFAULT_AI_SETTINGS;\n}\n\nexport async function saveAiSettings(db: D1Database, patch: Partial): Promise {\n try {\n const merged = mergeAiSettings(await getAiSettings(db), patch);\n const validated = AiSettingsSchema.parse(merged);\n await db.prepare(`\n INSERT INTO site_settings (key, value, category, type, updated_at)\n VALUES (?, ?, 'ai', 'json', CURRENT_TIMESTAMP)\n ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP\n `).bind(AI_SETTINGS_KEY, JSON.stringify(validated)).run();\n return true;\n } catch (e) {\n console.error('Failed to save AI settings:', e);\n return false;\n }\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/settings-store.test.ts src/modules/ai/__tests__/api-key.test.ts`\n→ Expected: PASS.\n\n- [x] **Step 6: Commit**\n\n```bash\ngit add src/modules/ai/settings-schema.ts src/modules/ai/api-key.ts src/modules/ai/settings-store.ts src/modules/ai/__tests__/settings-store.test.ts src/modules/ai/__tests__/api-key.test.ts\ngit commit -m \"feat(ai): validated deep-merge settings store + write-only key masking\"\n```\n\n---\n\n## Phase 2 — discovery core (normalize, classify, reconcile) + openai listModels\n\n**Files:**\n- Create: `src/modules/ai/discovery/normalize.ts`\n- Create: `src/modules/ai/discovery/reconcile.ts`\n- Create: `src/modules/ai/__tests__/normalize.test.ts`\n- Create: `src/modules/ai/__tests__/reconcile.test.ts`\n- Modify: `src/modules/ai/types.ts` (add `listModels` to `IAIProvider`)\n- Modify: `src/modules/ai/providers/openai.provider.ts`\n\n- [x] **Step 1: Write failing tests for `normalize.ts`**\n\n`src/modules/ai/__tests__/normalize.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { classifyModality, detectThinking, normalizeOpenAiModels } from '../discovery/normalize';\n\ndescribe('classifyModality', () => {\n it('classifies known non-text ids', () => {\n expect(classifyModality('dall-e-3')).toBe('image');\n expect(classifyModality('gpt-image-1')).toBe('image');\n expect(classifyModality('tts-1')).toBe('audio');\n expect(classifyModality('text-embedding-3-large')).toBe('embedding');\n expect(classifyModality('gpt-5.1-chat-latest')).toBe('text');\n });\n});\n\ndescribe('detectThinking', () => {\n it('flags reasoning model ids', () => {\n expect(detectThinking('o3-mini')).toBe(true);\n expect(detectThinking('deepseek-reasoner')).toBe(true);\n expect(detectThinking('gpt-4o')).toBe(false);\n });\n});\n\ndescribe('normalizeOpenAiModels', () => {\n it('maps an OpenAI /v1/models list to text-only DiscoveredModel[]', () => {\n const raw = { data: [{ id: 'gpt-5.1-chat-latest' }, { id: 'dall-e-3' }, { id: 'tts-1' }] };\n const models = normalizeOpenAiModels(raw);\n expect(models.map(m => m.id)).toEqual(['gpt-5.1-chat-latest']);\n expect(models[0]).toMatchObject({ id: 'gpt-5.1-chat-latest', modality: 'text', supports_thinking: false });\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts` → Expected: FAIL.\n\n- [x] **Step 2: Implement `discovery/normalize.ts`**\n\n```ts\nimport type { DiscoveredModel, ModelModality } from '../types';\n\nconst IMAGE_RE = /(dall-e|gpt-image|imagen|stable-diffusion|flux)/i;\nconst AUDIO_RE = /(tts|whisper|audio|speech|voice)/i;\nconst EMBED_RE = /(embedding|embed|rerank)/i;\nconst THINK_RE = /(^o\\d|reason|thinking|-r1\\b|deepseek-reasoner)/i;\n\nexport function classifyModality(id: string): ModelModality {\n if (IMAGE_RE.test(id)) return 'image';\n if (AUDIO_RE.test(id)) return 'audio';\n if (EMBED_RE.test(id)) return 'embedding';\n return 'text';\n}\n\nexport function detectThinking(id: string): boolean {\n return THINK_RE.test(id);\n}\n\n/** Normalize an OpenAI-compatible `{ data: [{ id }] }` models response to text-only models. */\nexport function normalizeOpenAiModels(raw: unknown): DiscoveredModel[] {\n const data = (raw as { data?: Array<{ id?: string }> })?.data ?? [];\n return data\n .map((m) => m?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n .map((id) => ({ id, modality: classifyModality(id), supports_thinking: detectThinking(id) }))\n .filter((m) => m.modality === 'text');\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Write failing tests for `reconcile.ts`**\n\n`src/modules/ai/__tests__/reconcile.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { reconcileSelection } from '../discovery/reconcile';\n\ndescribe('reconcileSelection', () => {\n const discovered = [{ id: 'a', modality: 'text' as const }, { id: 'b', modality: 'text' as const, deprecated: true }];\n it('marks selected, deprecated, and unavailable', () => {\n const stored = [\n { id: 'a', enabled: true, order: 0, modality: 'text' as const, status: 'available' as const, source: 'discovered' as const },\n { id: 'gone', enabled: true, order: 1, modality: 'text' as const, status: 'available' as const, source: 'discovered' as const },\n ];\n const view = reconcileSelection(stored, discovered);\n expect(view.find(m => m.id === 'a')).toMatchObject({ selected: true, status: 'available' });\n expect(view.find(m => m.id === 'b')).toMatchObject({ selected: false, status: 'deprecated' });\n expect(view.find(m => m.id === 'gone')).toMatchObject({ selected: true, status: 'unavailable' });\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/reconcile.test.ts` → Expected: FAIL.\n\n- [x] **Step 4: Implement `discovery/reconcile.ts`**\n\n```ts\nimport type { DiscoveredModel, ModelSelection, ModelStatus } from '../types';\n\nexport interface ReconciledModel extends DiscoveredModel {\n selected: boolean;\n status: ModelStatus;\n}\n\nexport function reconcileSelection(\n stored: ModelSelection[],\n discovered: DiscoveredModel[],\n): ReconciledModel[] {\n const storedById = new Map(stored.map((m) => [m.id, m]));\n const discoveredById = new Map(discovered.map((m) => [m.id, m]));\n\n const out: ReconciledModel[] = discovered.map((d) => ({\n ...d,\n selected: storedById.has(d.id),\n status: d.deprecated ? 'deprecated' : 'available',\n }));\n\n for (const s of stored) {\n if (!discoveredById.has(s.id)) {\n out.push({ id: s.id, name: s.name, modality: s.modality, supports_thinking: s.supports_thinking, selected: true, status: 'unavailable' });\n }\n }\n return out;\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/reconcile.test.ts` → Expected: PASS.\n\n- [x] **Step 5: Add `listModels` to `IAIProvider` and implement it for OpenAI**\n\nIn `types.ts`, extend the interface:\n```ts\nexport interface ListModelsResult { supported: boolean; models: DiscoveredModel[]; }\nexport interface IAIProvider {\n readonly provider: AIProvider;\n generateContent(request: GenerateContentRequest): Promise;\n validateApiKey(apiKey: string): Promise;\n listModels(apiKey: string): Promise;\n}\n```\n\nIn `openai.provider.ts`, add the method (reuse the existing `/v1/models` fetch):\n```ts\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n// ...\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch('https://api.openai.com/v1/models', {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nAdd the `DiscoveredModel` import to `openai.provider.ts`.\n\n- [x] **Step 6: Run the phase tests**\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts src/modules/ai/__tests__/reconcile.test.ts`\n→ Expected: PASS. (Full `pnpm typecheck` is still red until other providers gain `listModels` in\nPhase 3 — expected.)\n\n- [x] **Step 7: Commit**\n\n```bash\ngit add src/modules/ai/discovery src/modules/ai/__tests__/normalize.test.ts src/modules/ai/__tests__/reconcile.test.ts src/modules/ai/types.ts src/modules/ai/providers/openai.provider.ts\ngit commit -m \"feat(ai): discovery normalizer + obsolescence reconcile + openai listModels\"\n```\n\n---\n\n## Phase 3 — `listModels` for the remaining built-in providers\n\n**Files:**\n- Modify: `src/modules/ai/providers/{gemini,openrouter,mistral,moonshot,xai,anthropic,deepseek,qwen,zhipu}.provider.ts`\n\nFor each provider class, add a `listModels(apiKey)` method using the provider's models endpoint and\nthe shared normalizer where the response is OpenAI-shaped.\n\n- [x] **Step 1: OpenAI-compatible `/v1/models` providers**\n\nFor `openrouter` (`https://openrouter.ai/api/v1/models`), `mistral`\n(`https://api.mistral.ai/v1/models`), `moonshot` (`https://api.moonshot.cn/v1/models`), `xai`\n(`https://api.x.ai/v1/models`), `deepseek` (`https://api.deepseek.com/models`), `anthropic`\n(`https://api.anthropic.com/v1/models`, header `x-api-key` + `anthropic-version: 2023-06-01`), add:\n\n```ts\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n// ...\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch('', { headers: { } });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nUse `Authorization: Bearer ${apiKey}` for all except anthropic (`'x-api-key': apiKey,\n'anthropic-version': '2023-06-01'`). Anthropic and OpenRouter return `{ data: [{ id }] }`, which\n`normalizeOpenAiModels` already handles.\n\n- [x] **Step 2: Gemini (different response shape)**\n\n`gemini.provider.ts` — endpoint `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`\nreturns `{ models: [{ name: 'models/gemini-...' }] }`. Add a small local mapper:\n```ts\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);\n if (!res.ok) return { supported: true, models: [] };\n const raw = await res.json() as { models?: Array<{ name?: string }> };\n const ids = (raw.models ?? []).map(m => (m.name ?? '').replace(/^models\\//, '')).filter(Boolean);\n const models = ids.map(id => ({ id, modality: classifyModality(id), supports_thinking: detectThinking(id) }))\n .filter(m => m.modality === 'text');\n return { supported: true, models };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nImport `classifyModality, detectThinking` from `../discovery/normalize`.\n\n- [x] **Step 3: Providers without a usable list endpoint**\n\nFor `qwen` and `zhipu`, return unsupported so the UI falls back to manual/bulk:\n```ts\nasync listModels(): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n return { supported: false, models: [] };\n}\n```\n(If during implementation a working models endpoint is confirmed for either, implement it like\nStep 1 instead. Default to `supported: false` when unverified.)\n\n- [x] **Step 4: Verify every provider satisfies `IAIProvider`**\n\nRun: `pnpm typecheck 2>&1 | rg \"provider\" | head`\nExpected: no \"Property 'listModels' is missing\" errors for any provider class. (Other AI files may\nstill be red until Phase 7.)\n\n- [x] **Step 5: Commit**\n\n```bash\ngit add src/modules/ai/providers\ngit commit -m \"feat(ai): listModels for all built-in providers (qwen/zhipu fall back to manual)\"\n```\n\n---\n\n## Phase 4 — custom OpenAI-compatible providers\n\n**Files:**\n- Create: `src/modules/ai/providers/openai-compatible.provider.ts`\n- Modify: `src/modules/ai/ai.service.ts` (factory: default branch → custom provider)\n- Create: `src/pages/api/admin/ai/custom-providers/index.ts` (GET list, POST create)\n- Create: `src/pages/api/admin/ai/custom-providers/[id].ts` (PUT update, DELETE)\n- Modify: `src/shared/validation/schemas/ai.ts` (custom provider schemas)\n- Create: `src/modules/ai/__tests__/openai-compatible.test.ts`\n\n- [x] **Step 1: Failing test for the generic provider's normalization**\n\n`src/modules/ai/__tests__/openai-compatible.test.ts`:\n```ts\nimport { describe, it, expect, vi, afterEach } from 'vitest';\nimport { OpenAICompatibleProvider } from '../providers/openai-compatible.provider';\n\nafterEach(() => vi.restoreAllMocks());\n\ndescribe('OpenAICompatibleProvider.listModels', () => {\n it('lists text models from a custom base_url', async () => {\n vi.spyOn(globalThis, 'fetch').mockResolvedValue(\n new Response(JSON.stringify({ data: [{ id: 'custom-chat' }, { id: 'tts-x' }] }), { status: 200 }),\n );\n const p = new OpenAICompatibleProvider('my-endpoint', 'https://api.example.com/v1', 'k');\n const out = await p.listModels('k');\n expect(out.supported).toBe(true);\n expect(out.models.map(m => m.id)).toEqual(['custom-chat']);\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts` → Expected: FAIL.\n\n- [x] **Step 2: Implement `openai-compatible.provider.ts`**\n\n```ts\nimport type { IAIProvider, GenerateContentRequest, GenerateContentResponse, DiscoveredModel } from '../types';\nimport { getSystemPrompt } from '../prompts';\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n\nexport class OpenAICompatibleProvider implements IAIProvider {\n readonly provider: string;\n constructor(provider: string, private baseUrl: string, private apiKey: string) {\n this.provider = provider;\n }\n private url(path: string) { return `${this.baseUrl.replace(/\\/$/, '')}${path}`; }\n\n async generateContent(request: GenerateContentRequest): Promise {\n const systemPrompt = getSystemPrompt(request.content_type, request.system_prompt);\n try {\n const res = await fetch(this.url('/chat/completions'), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` },\n body: JSON.stringify({\n model: request.model,\n messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: request.prompt }],\n temperature: request.temperature ?? 0.7,\n response_format: { type: 'json_object' },\n }),\n });\n const data = await res.json() as { choices?: Array<{ message?: { content?: string } }>; error?: { message?: string } };\n if (data.error) return { success: false, error: data.error.message || 'Provider error' };\n const text = data.choices?.[0]?.message?.content;\n if (!text) return { success: false, error: 'No content generated' };\n return { success: true, data: JSON.parse(text) };\n } catch (e) {\n return { success: false, error: `Generation failed: ${e instanceof Error ? e.message : 'Unknown error'}` };\n }\n }\n\n async validateApiKey(apiKey: string): Promise {\n try { return (await fetch(this.url('/models'), { headers: { Authorization: `Bearer ${apiKey}` } })).ok; }\n catch { return false; }\n }\n\n async listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch(this.url('/models'), { headers: { Authorization: `Bearer ${apiKey}` } });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch { return { supported: true, models: [] }; }\n }\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Open the factory to custom providers (`ai.service.ts`)**\n\nChange `createProvider` to accept an optional base_url and route unknown ids to the generic class:\n```ts\nexport function createProvider(provider: AIProvider, apiKey: string, baseUrl?: string): IAIProvider {\n switch (provider) {\n case 'gemini': return new GeminiProvider(apiKey);\n case 'openai': return new OpenAIProvider(apiKey);\n case 'anthropic': return new AnthropicProvider(apiKey);\n case 'deepseek': return new DeepSeekProvider(apiKey);\n case 'openrouter': return new OpenRouterProvider(apiKey);\n case 'qwen': return new QwenProvider(apiKey);\n case 'zhipu': return new ZhipuProvider(apiKey);\n case 'moonshot': return new MoonshotProvider(apiKey);\n case 'mistral': return new MistralProvider(apiKey);\n case 'xai': return new XAIProvider(apiKey);\n default:\n if (!baseUrl) throw new Error(`Unknown provider: ${provider}`);\n return new OpenAICompatibleProvider(provider, baseUrl, apiKey);\n }\n}\n```\nImport `OpenAICompatibleProvider`.\n\n- [x] **Step 4: Custom-provider Zod schemas (`ai.ts`)**\n\nAdd to `src/shared/validation/schemas/ai.ts`:\n```ts\nexport const CreateCustomProviderSchema = z.object({\n id: z.string().min(1).regex(/^[a-z0-9-]+$/, 'id must be kebab-case'),\n label: z.string().min(1),\n base_url: z.string().url(),\n api_key: z.string().min(1),\n enabled: z.boolean().optional(),\n});\nexport const UpdateCustomProviderSchema = z.object({\n label: z.string().min(1).optional(),\n base_url: z.string().url().optional(),\n api_key: z.string().optional(),\n enabled: z.boolean().optional(),\n});\n```\n\n- [x] **Step 5: Custom-provider CRUD routes**\n\n`src/pages/api/admin/ai/custom-providers/index.ts` — `GET` returns\n`stripApiKeys(settings).custom_providers`; `POST` validates `CreateCustomProviderSchema` then\n`saveAiSettings(db, { custom_providers: { [id]: { label, base_url, api_key, enabled: enabled ?? true, models: [] } } })`.\n`src/pages/api/admin/ai/custom-providers/[id].ts` — `PUT` validates `UpdateCustomProviderSchema`\nthen `saveAiSettings(db, { custom_providers: { [id]: patch } })` (deep merge keeps key/models);\n`DELETE` loads settings, removes the id from `custom_providers`, writes the full object via a\ndedicated `replaceAiSettings` (full overwrite) — add `replaceAiSettings(db, settings)` to\n`settings-store.ts` that validates + writes without merge. Both require `AuthRoles.ADMIN` via\n`extractAuthContext`/`hasRole` (follow `migrate-models.ts` auth pattern).\n\n- [x] **Step 6: Verify + commit**\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts`\n→ Expected: PASS.\n```bash\ngit add src/modules/ai/providers/openai-compatible.provider.ts src/modules/ai/ai.service.ts src/modules/ai/settings-store.ts src/shared/validation/schemas/ai.ts src/pages/api/admin/ai/custom-providers src/modules/ai/__tests__/openai-compatible.test.ts\ngit commit -m \"feat(ai): OpenAI-compatible custom providers + CRUD\"\n```\n\n---\n\n## Phase 5 — discovery + settings + providers + generate endpoints (snake_case)\n\n**Files:**\n- Create: `src/pages/api/admin/ai/models/[provider]/discover.ts`\n- Modify: `src/pages/api/admin/ai/settings.ts`, `providers.ts`, `generate.ts`,\n `models/[provider].ts`, `models/[provider]/[modelId].ts`\n- Delete: `src/pages/api/admin/ai/migrate-models.ts`\n- Modify: `src/shared/validation/schemas/ai.ts` (snake_case settings/generate schemas)\n- Modify: `src/modules/ai/ai.service.ts` (`getModelsForProvider`, `generateContent`,\n `getConfiguredProviders`, resolution helper)\n\n- [x] **Step 1: Rewrite `ai.ts` request schemas to snake_case**\n\nUpdate `GenerateSchema`, `UpdateSettingsSchema`, `AddModelSchema`, `UpdateModelSchema`,\n`ProviderModelParam`, `ValidateApiKeySchema` to snake_case keys:\n```ts\nexport const GenerateSchema = z.object({\n prompt: z.string().min(3).max(10000),\n content_type: z.enum(['recipe', 'article', 'roundup']),\n provider: z.string().optional(),\n model: z.string().min(1).optional(),\n temperature: z.number().min(0).max(2).optional(),\n reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),\n}).passthrough();\n\nexport const UpdateSettingsSchema = z.object({\n default_provider: z.string().optional(),\n default_model: z.string().optional(),\n temperature: z.number().min(0).max(1).optional(),\n system_prompt: z.string().optional(),\n providers: z.record(z.string(), z.object({\n api_key: z.string().optional(),\n enabled: z.boolean().optional(),\n models: z.array(z.any()).optional(),\n })).optional(),\n}).passthrough();\n\nexport const AddModelSchema = z.object({\n id: z.string().min(1),\n name: z.string().min(1),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n}).passthrough();\n\nexport const UpdateModelSchema = z.object({\n name: z.string().min(1).optional(),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n deprecated: z.boolean().optional(),\n enabled: z.boolean().optional(),\n}).passthrough();\n\nexport const ProviderModelParam = z.object({ provider: z.string().min(1), model_id: z.string().min(1) });\nexport const ValidateApiKeySchema = z.object({ provider: z.string().min(1), api_key: z.string().min(1) });\n```\n(`ProviderEnum` is replaced by `z.string()` since custom providers exist; validity is checked\nagainst settings at runtime.)\n\n- [x] **Step 2: Discovery endpoint**\n\n`src/pages/api/admin/ai/models/[provider]/discover.ts` (`GET`, `AuthRoles.ADMIN`):\n```ts\n// resolve provider config (built-in providers[p] or custom_providers[p]) from getAiSettings\n// const cfg = settings.providers[provider] ?? settings.custom_providers[provider];\n// if (!cfg?.api_key) -> 400 \"No API key configured for \"\n// const impl = createProvider(provider, cfg.api_key, (cfg as CustomProviderConfig).base_url);\n// const { supported, models } = await impl.listModels(cfg.api_key);\n// const view = reconcileSelection(cfg.models ?? [], models);\n// return formatSuccessResponse({ supported, models: view });\n```\nUse `formatSuccessResponse`/`formatErrorResponse` and the `validateParams`-style param read used by\nsibling routes.\n\n- [x] **Step 3: `settings.ts` GET (masked) / PUT (deep merge)**\n\nGET: `formatSuccessResponse(stripApiKeys(await getAiSettings(db)))`.\nPUT: `validateBody(request, UpdateSettingsSchema)` → `saveAiSettings(db, body)` → return\n`stripApiKeys(await getAiSettings(db))`.\nPOST (validate key): `createProvider(provider, api_key, base_url?).validateApiKey(api_key)`.\nRemove every camelCase field; require `AuthRoles.ADMIN`.\n\n- [x] **Step 4: `providers.ts`, `generate.ts`, model CRUD to snake_case + selection**\n\n`providers.ts` GET → `{ configured_providers, available_models, provider_info, defaults }` where\n`available_models[p] = getModelsForProvider(db, p)` (stored selection, enabled only). Strip keys.\n`generate.ts` → read `GenerateSchema`, call `generateContent(db, { ...body })`, snake_case response.\n`models/[provider].ts` (POST add / GET list) and `models/[provider]/[modelId].ts` (PUT/DELETE) →\noperate on `providers[p].models` (or `custom_providers[p].models`) via `saveAiSettings`, snake_case\nfields, `source: 'manual'` for manually added models.\n\n- [x] **Step 5: `ai.service.ts` resolution + generate**\n\n```ts\nexport async function getModelsForProvider(db: D1Database, provider: AIProvider): Promise {\n const settings = await getAiSettings(db);\n const cfg = settings.providers[provider as BuiltInProvider] ?? settings.custom_providers[provider];\n return (cfg?.models ?? []).filter(m => m.enabled && m.status !== 'unavailable');\n}\n\nexport async function generateContent(db: D1Database, request: GenerateContentRequest): Promise {\n const settings = await getAiSettings(db);\n const provider = request.provider || settings.default_provider;\n const cfg = settings.providers[provider as BuiltInProvider] ?? settings.custom_providers[provider];\n if (!cfg?.api_key) return { success: false, error: `No API key configured for provider: ${provider}` };\n if (!cfg.enabled) return { success: false, error: `Provider is disabled: ${provider}` };\n const impl = createProvider(provider, cfg.api_key, (cfg as CustomProviderConfig).base_url);\n const model = request.model || (provider === settings.default_provider ? settings.default_model : request.model);\n return impl.generateContent({\n ...request,\n model: model || request.model,\n temperature: request.temperature ?? settings.temperature,\n system_prompt: request.system_prompt || settings.system_prompt,\n });\n}\n```\nUpdate `getConfiguredProviders` to iterate `providers` + `custom_providers` reading `api_key`/`enabled`.\n\n- [x] **Step 6: Delete the obsolete migration endpoint**\n\n```bash\ngit rm src/pages/api/admin/ai/migrate-models.ts\n```\nThen `rg -n \"migrate-models|AVAILABLE_MODELS\" src` and remove every remaining reference.\n\n- [x] **Step 7: Verify + commit**\n\nRun: `pnpm test && pnpm check:boundaries`\nExpected: PASS / \"Boundary check passed.\" (`pnpm typecheck` may still be red until admin UI in\nPhase 7.)\n```bash\ngit add -A\ngit commit -m \"feat(ai): discovery + snake_case settings/providers/generate endpoints; drop migrate-models\"\n```\n\n---\n\n## Phase 6 — thinking / reasoning mapping at the provider boundary\n\n**Files:**\n- Modify: providers that support reasoning — `openai`, `anthropic`, `deepseek`, `openai-compatible`\n- Modify: `src/modules/ai/__tests__/...` (one mapping test)\n\n- [x] **Step 1: Map `reasoning_effort` per provider at the fetch boundary**\n\nIn `openai.provider.ts` and `openai-compatible.provider.ts`, when `request.reasoning_effort` is set,\nadd `reasoning_effort: request.reasoning_effort` to the request body. In `anthropic.provider.ts`,\nmap to `thinking: { type: 'enabled', budget_tokens: }`. In\n`deepseek.provider.ts`, no body change is required (reasoner is model-selected) — leave a comment.\nProviders without support ignore the field (no change).\n\n- [x] **Step 2: Test the mapping (openai body includes reasoning_effort)**\n\nAdd to an existing/new provider test that mocks `fetch` and asserts the request body passed to\n`fetch` contains `reasoning_effort: 'high'` when set. Run that test → Expected: PASS.\n\n- [x] **Step 3: Commit**\n\n```bash\ngit add src/modules/ai/providers src/modules/ai/__tests__\ngit commit -m \"feat(ai): map reasoning_effort to provider-native thinking params\"\n```\n\n---\n\n## Phase 7 — admin UI: snake_case + discovery UX + has_api_key\n\n**Files:**\n- Modify: `src/admin/services/api.ts` (AI client methods)\n- Modify: `src/admin/components/ModelManager.tsx`, `BulkImportModels.tsx`\n- Modify: `src/admin/features/settings/pages/tabs/AISettings.tsx`,\n `src/admin/components/BlockEditor/components/AISettings.tsx`,\n `src/admin/features/settings/pages/Settings.tsx`\n\n- [x] **Step 1: `api.ts` AI client methods → snake_case + discover + custom providers**\n\nAdd/repoint methods: `aiSettings.get/update`, `aiProviders.get`, `aiModels.discover(provider)`,\n`aiModels.add/update/remove`, `aiCustomProviders.list/create/update/remove`, `aiGenerate(payload)`.\nAll payloads/reads use snake_case (`content_type`, `reasoning_effort`, `default_provider`,\n`has_api_key`, `context_window`, `max_tokens`).\n\n- [x] **Step 2: ModelManager — discovery checklist**\n\nRework `ManagedModel` to snake_case (`context_window`, `max_tokens`, `enabled`, `deprecated`,\n`status`). Add a \"Fetch latest models\" button calling `aiModels.discover(provider)`; render the\nreturned `models` as a checklist with `status` badges (`unavailable`/`deprecated`); ticking a model\nsaves it into the selection via `aiSettings.update`/`aiModels.add`. Keep manual add + bulk import\nvisible as fallback (and shown automatically when `discover` returns `supported: false`).\n\n- [x] **Step 3: BulkImportModels + both AISettings tabs + Settings.tsx**\n\nRename all AI data-key reads/writes to snake_case (`context_window`, `max_tokens`, `api_key`→write\nonly, render `has_api_key`/`api_key_masked` instead of the raw key, `default_provider`,\n`default_model`, `system_prompt`, `reasoning_effort` selector shown when the chosen model has\n`supports_thinking`). Add a custom-provider add/edit form (id/label/base_url/api_key) backed by\n`aiCustomProviders`.\n\n- [x] **Step 4: Verify full green**\n\nRun: `pnpm typecheck && pnpm test && pnpm check:boundaries`\nExpected: all green (this is the phase where typecheck returns fully clean).\n\n- [x] **Step 5: Commit**\n\n```bash\ngit add src/admin\ngit commit -m \"feat(ai): snake_case admin AI UI + model discovery UX + write-only keys\"\n```\n\n---\n\n## Phase 8 — one-shot stored-blob data migration\n\n**Files:**\n- Create: `scripts/migrate-ai-settings.mts`\n- Create: `src/modules/ai/__tests__/migrate-ai-settings.test.ts`\n\n- [x] **Step 1: Failing test for the pure transform**\n\n`src/modules/ai/__tests__/migrate-ai-settings.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { migrateAiSettingsBlob } from '../../../scripts/migrate-ai-settings.mts';\n\ndescribe('migrateAiSettingsBlob', () => {\n it('converts legacy camelCase blob to snake_case selection shape', () => {\n const legacy = {\n defaultProvider: 'openai', defaultModel: 'gpt-4o', temperature: 0.7, systemPrompt: 'sp',\n providers: { openai: { apiKey: 'sk-1', enabled: true,\n availableModels: [{ id: 'gpt-4o', name: 'GPT-4o', contextWindow: 131072, maxTokens: 65536, enabled: true }] } },\n };\n const out = migrateAiSettingsBlob(legacy);\n expect(out.default_provider).toBe('openai');\n expect(out.system_prompt).toBe('sp');\n expect(out.providers.openai.api_key).toBe('sk-1');\n expect(out.providers.openai.models[0]).toMatchObject({\n id: 'gpt-4o', context_window: 131072, max_tokens: 65536, modality: 'text',\n status: 'available', source: 'discovered', enabled: true,\n });\n expect(out.custom_providers).toEqual({});\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/migrate-ai-settings.test.ts` → Expected: FAIL.\n\n- [x] **Step 2: Implement `scripts/migrate-ai-settings.mts`**\n\nExport a pure `migrateAiSettingsBlob(legacy)` (camelCase → snake_case, `availableModels` → `models`\nwith `modality:'text'`, `status:'available'`, `source:'discovered'`, `order` by index) plus a\n`main()` that opens the local D1 sqlite (reuse the `findDb` pattern from\n`scripts/migrate-credit-avatar-r2key.mts`), reads `site_settings.value WHERE key='ai_settings'`,\nprints a dry-run diff, and writes the transformed blob under `--apply`. Validate the result with\n`AiSettingsSchema` before writing.\n\nRun: `pnpm test src/modules/ai/__tests__/migrate-ai-settings.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Dry-run against local D1**\n\nRun: `node_modules/.bin/tsx scripts/migrate-ai-settings.mts`\nExpected: prints the would-be snake_case blob; no write. (If no `ai_settings` row exists locally,\nit prints \"no ai_settings row — nothing to migrate\".)\n\n- [x] **Step 4: Commit**\n\n```bash\ngit add scripts/migrate-ai-settings.mts src/modules/ai/__tests__/migrate-ai-settings.test.ts\ngit commit -m \"chore(ai): one-shot migration of stored ai_settings to snake_case selection shape\"\n```\n\n---\n\n## Phase 9 — finalize & close AI portion of #3\n\n**Files:**\n- Modify: `docs/NAMING_CONTRACT.md` (only if it still implies AI is pending)\n- Modify: `.hermes/plans/2026-06-03_contract-audit-report.md` (local record)\n\n- [x] **Step 1: Full regression gate**\n\nRun: `pnpm typecheck && pnpm test && pnpm check:boundaries`\nExpected: all green.\n\n- [x] **Step 2: Contract audit — no AI camelCase regressions**\n\nRun: `rg -n \"\\b(defaultProvider|defaultModel|systemPrompt|apiKey|availableModels|contextWindow|maxTokens|modelId)\\b\" src/modules/ai src/pages/api/admin/ai src/admin --glob '!**/*.test.*'`\nExpected: matches only inside provider classes at the external fetch boundary (allowed exception)\nand local variables — no app-owned data-shape keys. Then\n`node scripts/local-contract-audit.mjs --summary` → no new camelCase violation.\n\n- [x] **Step 3: Commit (if docs changed)**\n\n```bash\ngit add docs/NAMING_CONTRACT.md\ngit commit -m \"docs(naming): AI subsystem migrated to snake_case (audit #3)\"\n```\n\n---\n\n## Self-review notes\n\n- **Spec coverage:** stored shape (P0/P1/P5), discovery `listModels` (P2/P3), discover endpoint\n (P5), custom providers (P4), safe persistence + Zod (P1), key masking (P1/P5), obsolescence\n reconcile (P2 + P5 resolution filter), thinking (P6), modality filter (P2 normalizer), snake_case\n E2E (P0/P5/P7), data migration (P8), cleanup of AVAILABLE_MODELS + migrate-models (P5). ✓\n- **No placeholders:** pure-logic units ship full code + tests; mechanical endpoint/UI tasks give\n exact files, exact snake_case key lists, and a shared normalizer (DRY) rather than repeated\n provider code. ✓\n- **Type consistency:** `AiSettings`, `ModelSelection`, `DiscoveredModel`, `ListModelsResult`,\n `reconcileSelection`, `mergeAiSettings`, `getAiSettings`/`saveAiSettings`/`replaceAiSettings`,\n `stripApiKeys`/`maskApiKey`, `normalizeOpenAiModels`/`classifyModality`/`detectThinking` are used\n with consistent names across phases. ✓\n- **Typecheck phasing:** Phases 1, 2, 3, 8 are green in isolation; Phases 0, 4–7 may leave global\n typecheck red until Phase 7 (documented in the Phase 0 note); every phase's own unit tests pass.\n"}, "890329c96f50425d4af9f9a2a064a0c38d5e4157": {"t": 1785765911.1106796, "n": 47628, "h": "c03b5685b39867f5", "c": "# AI Model Management Redesign Implementation Plan\n\n> **STATUS — 2026-06-04: ✅ COMPLETED, REVIEWED & FIXED.** All phases implemented (snake_case types/store/discovery/providers/custom providers/endpoints/reasoning/admin UI + data migration). Code review fixes applied (`a71be82`: anthropic max_tokens>budget, provider-id collision guard, un-deprecate, editor reasoning_effort). Stored `ai_settings` blob migrated locally (`migrate-ai-settings --apply`). Verified: typecheck 0 · tests green · boundaries ✅. Ops: run `migrate-ai-settings --apply` on prod D1 at deploy.\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Replace the hardcoded AI model catalog with live per-provider discovery, store only the admin's curated snake_case selection with safe validated persistence and write-only keys, add OpenAI-compatible custom providers, model-obsolescence reconciliation, and a thinking/reasoning control — closing the AI portion of Contract Audit #3.\n\n**Architecture:** Pure-logic units (snake_case types, Zod stored-blob schema, deep-merge persistence, key masking, model normalization/modality classification, obsolescence reconciliation) are built test-first. Provider `listModels` implementations share one OpenAI-style normalizer. API routes, the admin UI, and the client are migrated to snake_case and wired to discovery. A one-shot script migrates the existing stored blob.\n\n**Tech Stack:** Cloudflare D1 (`site_settings`), Zod, Astro API routes, React admin SPA, Vitest, `node:sqlite` for the migration script.\n\n**Spec:** `docs/superpowers/specs/2026-06-04-ai-model-management-redesign-design.md`\n\n---\n\n## Conventions (read once)\n\n- **snake_case** for all app-owned data keys (stored blob, our API envelopes, admin props, types\n that model them). Local variables/functions/React state may stay camelCase (`NAMING_CONTRACT`\n L21). External provider HTTP request/response field names stay as the provider names **only\n inside each provider class** at the fetch boundary (Allowed Exception).\n- **TDD** for pure units (types are compile-time; logic units get a failing test first). Mechanical\n snake_case renames and UI wiring are verified by `pnpm test && pnpm check:boundaries &&\n pnpm typecheck`.\n- **Run between every task:** `pnpm test && pnpm check:boundaries && pnpm typecheck`. All must be\n green before the task's commit.\n- Test files live next to the code under `__tests__/` per existing convention\n (e.g. `src/modules/ai/__tests__/...`).\n\n## File structure (created / modified)\n\n- `src/modules/ai/types.ts` — replace AISettings/AIModel/AVAILABLE_MODELS with snake_case\n `AiSettings`, `ModelSelection`, `DiscoveredModel`, capability enums; keep `ALL_PROVIDERS`,\n `PROVIDER_INFO`, `DEFAULT_AI_SETTINGS` (snake).\n- `src/modules/ai/settings-schema.ts` (new) — Zod schema for the stored blob.\n- `src/modules/ai/settings-store.ts` (new) — `getAiSettings` / `saveAiSettings` (deep merge +\n validate) extracted from `ai.service.ts`.\n- `src/modules/ai/api-key.ts` (new) — `maskApiKey`, `stripApiKeys`.\n- `src/modules/ai/discovery/normalize.ts` (new) — `normalizeOpenAiModels`, `classifyModality`,\n `detectThinking`.\n- `src/modules/ai/discovery/reconcile.ts` (new) — `reconcileSelection`.\n- `src/modules/ai/providers/openai-compatible.provider.ts` (new) — generic custom provider.\n- `src/modules/ai/providers/*.provider.ts` — add `listModels`; snake_case app-owned fields.\n- `src/modules/ai/ai.service.ts` — discovery resolution, generate with `reasoning_effort`,\n open-ended factory.\n- `src/shared/validation/schemas/ai.ts` — snake_case Zod schemas + `reasoning_effort`.\n- `src/pages/api/admin/ai/models/[provider]/discover.ts` (new) — discovery endpoint.\n- `src/pages/api/admin/ai/settings.ts`, `providers.ts`, `generate.ts`,\n `models/[provider]/[modelId].ts`, `models/[provider].ts` — snake_case + masking + custom CRUD.\n- `src/pages/api/admin/ai/migrate-models.ts` — **delete**.\n- `src/pages/api/admin/ai/custom-providers/*` (new) — custom provider CRUD.\n- `src/admin/components/ModelManager.tsx`, `BulkImportModels.tsx`,\n `src/admin/features/settings/pages/tabs/AISettings.tsx`,\n `src/admin/components/BlockEditor/components/AISettings.tsx`,\n `src/admin/features/settings/pages/Settings.tsx`, `src/admin/services/api.ts` — snake_case +\n discovery UX + `has_api_key`.\n- `scripts/migrate-ai-settings.mts` (new) — one-shot stored-blob migration.\n\n---\n\n## Phase 0 — snake_case types & defaults\n\n**Files:**\n- Modify: `src/modules/ai/types.ts`\n\n- [ ] **Step 1: Replace the data-shape interfaces with snake_case**\n\nReplace `AIModel`, `AISettings`, `DEFAULT_AI_SETTINGS`, and remove `AVAILABLE_MODELS`. Keep\n`AIProvider`, `ALL_PROVIDERS`, `PROVIDER_INFO`, `IAIProvider`, `GenerateContentRequest`,\n`GeneratedRecipe`. New definitions:\n\n```ts\n/** Built-in provider id, or a custom OpenAI-compatible provider id (slug). */\nexport type BuiltInProvider =\n | 'gemini' | 'openai' | 'anthropic' | 'deepseek' | 'openrouter'\n | 'qwen' | 'zhipu' | 'moonshot' | 'mistral' | 'xai';\nexport type AIProvider = BuiltInProvider | (string & {});\n\nexport type ModelModality = 'text' | 'image' | 'audio' | 'embedding' | 'other';\nexport type ModelStatus = 'available' | 'unavailable' | 'deprecated';\n\n/** A model the provider currently offers (discovery output). */\nexport interface DiscoveredModel {\n id: string;\n name?: string;\n context_window?: number;\n max_tokens?: number;\n modality: ModelModality;\n supports_thinking?: boolean;\n deprecated?: boolean;\n}\n\n/** A model the admin has curated into the stored selection. */\nexport interface ModelSelection {\n id: string;\n name?: string;\n context_window?: number;\n max_tokens?: number;\n modality: ModelModality;\n supports_thinking?: boolean;\n enabled: boolean;\n order: number;\n deprecated?: boolean;\n status: ModelStatus;\n source: 'discovered' | 'manual';\n}\n\nexport interface ProviderConfig {\n enabled: boolean;\n api_key: string;\n models: ModelSelection[];\n}\n\nexport interface CustomProviderConfig extends ProviderConfig {\n label: string;\n base_url: string;\n}\n\nexport interface AiSettings {\n default_provider: AIProvider;\n default_model: string;\n temperature: number;\n system_prompt: string;\n providers: Partial>;\n custom_providers: Record;\n}\n\nexport const DEFAULT_AI_SETTINGS: AiSettings = {\n default_provider: 'gemini',\n default_model: 'gemini-3-flash-preview',\n temperature: 0.7,\n system_prompt: `You are an expert culinary content creator. Generate structured recipe and article content in JSON format.\nAlways respond with valid JSON that matches the expected schema.\nBe creative, detailed, and provide practical cooking tips.`,\n providers: {},\n custom_providers: {},\n};\n```\n\nUpdate `GenerateContentRequest` to snake_case app-owned fields and add the reasoning control:\n\n```ts\nexport interface GenerateContentRequest {\n prompt: string;\n content_type: 'recipe' | 'article' | 'roundup';\n provider: AIProvider;\n model: string;\n temperature?: number;\n system_prompt?: string;\n reasoning_effort?: 'low' | 'medium' | 'high';\n}\n```\n\n- [ ] **Step 2: Typecheck (expect downstream breakages — they are fixed in later phases)**\n\nRun: `pnpm typecheck 2>&1 | head -40`\nExpected: errors only in AI files that consume the old names (ai.service, providers, endpoints,\nadmin). These are addressed in Phases 1–7. Do NOT fix unrelated files here.\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/modules/ai/types.ts\ngit commit -m \"refactor(ai): snake_case AiSettings + add discovery/selection model types\"\n```\n\n> Note for the executor: Phases 0–7 form one compile-coherent unit. Commit per phase, but\n> `pnpm typecheck` only returns fully green at the end of Phase 7. Phases that CAN be green in\n> isolation (1, 2, 3, 8) state so; Phases 0, 4–7 may leave typecheck red until Phase 7. Each\n> phase's own unit tests must pass.\n\n---\n\n## Phase 1 — safe persistence, validation, key masking\n\n**Files:**\n- Create: `src/modules/ai/settings-schema.ts`\n- Create: `src/modules/ai/api-key.ts`\n- Create: `src/modules/ai/settings-store.ts`\n- Create: `src/modules/ai/__tests__/settings-store.test.ts`\n- Create: `src/modules/ai/__tests__/api-key.test.ts`\n\n- [ ] **Step 1: Write failing tests for api-key masking**\n\n`src/modules/ai/__tests__/api-key.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { maskApiKey, stripApiKeys } from '../api-key';\n\ndescribe('maskApiKey', () => {\n it('masks all but the last 4 chars', () => {\n expect(maskApiKey('sk-abcdef1234')).toBe('••••1234');\n });\n it('returns empty string for empty/undefined', () => {\n expect(maskApiKey('')).toBe('');\n expect(maskApiKey(undefined)).toBe('');\n });\n});\n\ndescribe('stripApiKeys', () => {\n it('replaces api_key with has_api_key + masked across providers and custom_providers', () => {\n const out = stripApiKeys({\n default_provider: 'openai', default_model: 'm', temperature: 0.7, system_prompt: 's',\n providers: { openai: { enabled: true, api_key: 'sk-xxxx9999', models: [] } },\n custom_providers: { c1: { label: 'C', base_url: 'u', enabled: true, api_key: 'k-1111', models: [] } },\n });\n expect(out.providers.openai).toMatchObject({ has_api_key: true, api_key_masked: '••••9999' });\n expect('api_key' in out.providers.openai).toBe(false);\n expect(out.custom_providers.c1).toMatchObject({ has_api_key: true, api_key_masked: '••••1111' });\n expect('api_key' in out.custom_providers.c1).toBe(false);\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/api-key.test.ts` → Expected: FAIL (module missing).\n\n- [ ] **Step 2: Implement `api-key.ts`**\n\n```ts\nimport type { AiSettings } from './types';\n\nexport function maskApiKey(key: string | undefined): string {\n if (!key) return '';\n return `••••${key.slice(-4)}`;\n}\n\ntype StrippedProvider = { enabled: boolean; models: unknown[]; has_api_key: boolean; api_key_masked: string };\n\nfunction strip(cfg: T): Omit & { has_api_key: boolean; api_key_masked: string } {\n const { api_key, ...rest } = cfg;\n return { ...rest, has_api_key: Boolean(api_key), api_key_masked: maskApiKey(api_key) };\n}\n\nexport function stripApiKeys(settings: AiSettings) {\n return {\n ...settings,\n providers: Object.fromEntries(\n Object.entries(settings.providers).map(([k, v]) => [k, strip(v!)]),\n ),\n custom_providers: Object.fromEntries(\n Object.entries(settings.custom_providers).map(([k, v]) => [k, strip(v)]),\n ),\n };\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/api-key.test.ts` → Expected: PASS.\n\n- [ ] **Step 3: Implement the Zod stored-blob schema (`settings-schema.ts`)**\n\n```ts\nimport { z } from 'zod';\n\nconst ModelSelectionSchema = z.object({\n id: z.string().min(1),\n name: z.string().optional(),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n modality: z.enum(['text', 'image', 'audio', 'embedding', 'other']).default('text'),\n supports_thinking: z.boolean().optional(),\n enabled: z.boolean().default(true),\n order: z.number().int().nonnegative().default(0),\n deprecated: z.boolean().optional(),\n status: z.enum(['available', 'unavailable', 'deprecated']).default('available'),\n source: z.enum(['discovered', 'manual']).default('discovered'),\n});\n\nconst ProviderConfigSchema = z.object({\n enabled: z.boolean().default(false),\n api_key: z.string().default(''),\n models: z.array(ModelSelectionSchema).default([]),\n});\n\nconst CustomProviderConfigSchema = ProviderConfigSchema.extend({\n label: z.string().min(1),\n base_url: z.string().url(),\n});\n\nexport const AiSettingsSchema = z.object({\n default_provider: z.string().min(1),\n default_model: z.string().default(''),\n temperature: z.number().min(0).max(2).default(0.7),\n system_prompt: z.string().default(''),\n providers: z.record(z.string(), ProviderConfigSchema).default({}),\n custom_providers: z.record(z.string(), CustomProviderConfigSchema).default({}),\n});\n```\n\n- [ ] **Step 4: Write failing tests for the store (deep merge + validate + fallback)**\n\n`src/modules/ai/__tests__/settings-store.test.ts`:\n```ts\nimport { describe, it, expect, vi } from 'vitest';\nimport { mergeAiSettings } from '../settings-store';\nimport { DEFAULT_AI_SETTINGS } from '../types';\n\ndescribe('mergeAiSettings', () => {\n it('deep-merges one provider without dropping others', () => {\n const current = {\n ...DEFAULT_AI_SETTINGS,\n providers: {\n openai: { enabled: true, api_key: 'sk-1', models: [] },\n gemini: { enabled: true, api_key: 'g-1', models: [] },\n },\n };\n const merged = mergeAiSettings(current, { providers: { openai: { enabled: false } } });\n expect(merged.providers.gemini).toEqual({ enabled: true, api_key: 'g-1', models: [] });\n expect(merged.providers.openai).toMatchObject({ enabled: false, api_key: 'sk-1' });\n });\n\n it('keeps existing api_key when the patch omits it', () => {\n const current = { ...DEFAULT_AI_SETTINGS, providers: { openai: { enabled: true, api_key: 'sk-keep', models: [] } } };\n const merged = mergeAiSettings(current, { providers: { openai: { models: [] } } });\n expect(merged.providers.openai.api_key).toBe('sk-keep');\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/settings-store.test.ts` → Expected: FAIL.\n\n- [ ] **Step 5: Implement `settings-store.ts`**\n\n```ts\nimport type { D1Database } from '@cloudflare/workers-types';\nimport type { AiSettings } from './types';\nimport { DEFAULT_AI_SETTINGS } from './types';\nimport { AiSettingsSchema } from './settings-schema';\n\nconst AI_SETTINGS_KEY = 'ai_settings';\n\nfunction mergeProviderMap>(current: T, patch: Partial | undefined): T {\n if (!patch) return current;\n const out: Record = { ...current };\n for (const [id, cfg] of Object.entries(patch)) {\n out[id] = { ...(current[id] ?? {}), ...(cfg as object) };\n }\n return out as T;\n}\n\nexport function mergeAiSettings(current: AiSettings, patch: Partial): AiSettings {\n return {\n ...current,\n ...patch,\n providers: mergeProviderMap(current.providers, patch.providers),\n custom_providers: mergeProviderMap(current.custom_providers, patch.custom_providers),\n };\n}\n\nexport async function getAiSettings(db: D1Database): Promise {\n try {\n const row = await db.prepare('SELECT value FROM site_settings WHERE key = ?')\n .bind(AI_SETTINGS_KEY).first<{ value: string }>();\n if (row?.value) {\n const parsed = AiSettingsSchema.safeParse(JSON.parse(row.value));\n if (parsed.success) return parsed.data as AiSettings;\n console.error('Invalid ai_settings blob, using defaults:', parsed.error.issues);\n }\n } catch (e) {\n console.error('Failed to load AI settings:', e);\n }\n return DEFAULT_AI_SETTINGS;\n}\n\nexport async function saveAiSettings(db: D1Database, patch: Partial): Promise {\n try {\n const merged = mergeAiSettings(await getAiSettings(db), patch);\n const validated = AiSettingsSchema.parse(merged);\n await db.prepare(`\n INSERT INTO site_settings (key, value, category, type, updated_at)\n VALUES (?, ?, 'ai', 'json', CURRENT_TIMESTAMP)\n ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP\n `).bind(AI_SETTINGS_KEY, JSON.stringify(validated)).run();\n return true;\n } catch (e) {\n console.error('Failed to save AI settings:', e);\n return false;\n }\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/settings-store.test.ts src/modules/ai/__tests__/api-key.test.ts`\n→ Expected: PASS.\n\n- [ ] **Step 6: Commit**\n\n```bash\ngit add src/modules/ai/settings-schema.ts src/modules/ai/api-key.ts src/modules/ai/settings-store.ts src/modules/ai/__tests__/settings-store.test.ts src/modules/ai/__tests__/api-key.test.ts\ngit commit -m \"feat(ai): validated deep-merge settings store + write-only key masking\"\n```\n\n---\n\n## Phase 2 — discovery core (normalize, classify, reconcile) + openai listModels\n\n**Files:**\n- Create: `src/modules/ai/discovery/normalize.ts`\n- Create: `src/modules/ai/discovery/reconcile.ts`\n- Create: `src/modules/ai/__tests__/normalize.test.ts`\n- Create: `src/modules/ai/__tests__/reconcile.test.ts`\n- Modify: `src/modules/ai/types.ts` (add `listModels` to `IAIProvider`)\n- Modify: `src/modules/ai/providers/openai.provider.ts`\n\n- [ ] **Step 1: Write failing tests for `normalize.ts`**\n\n`src/modules/ai/__tests__/normalize.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { classifyModality, detectThinking, normalizeOpenAiModels } from '../discovery/normalize';\n\ndescribe('classifyModality', () => {\n it('classifies known non-text ids', () => {\n expect(classifyModality('dall-e-3')).toBe('image');\n expect(classifyModality('gpt-image-1')).toBe('image');\n expect(classifyModality('tts-1')).toBe('audio');\n expect(classifyModality('text-embedding-3-large')).toBe('embedding');\n expect(classifyModality('gpt-5.1-chat-latest')).toBe('text');\n });\n});\n\ndescribe('detectThinking', () => {\n it('flags reasoning model ids', () => {\n expect(detectThinking('o3-mini')).toBe(true);\n expect(detectThinking('deepseek-reasoner')).toBe(true);\n expect(detectThinking('gpt-4o')).toBe(false);\n });\n});\n\ndescribe('normalizeOpenAiModels', () => {\n it('maps an OpenAI /v1/models list to text-only DiscoveredModel[]', () => {\n const raw = { data: [{ id: 'gpt-5.1-chat-latest' }, { id: 'dall-e-3' }, { id: 'tts-1' }] };\n const models = normalizeOpenAiModels(raw);\n expect(models.map(m => m.id)).toEqual(['gpt-5.1-chat-latest']);\n expect(models[0]).toMatchObject({ id: 'gpt-5.1-chat-latest', modality: 'text', supports_thinking: false });\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts` → Expected: FAIL.\n\n- [ ] **Step 2: Implement `discovery/normalize.ts`**\n\n```ts\nimport type { DiscoveredModel, ModelModality } from '../types';\n\nconst IMAGE_RE = /(dall-e|gpt-image|imagen|stable-diffusion|flux)/i;\nconst AUDIO_RE = /(tts|whisper|audio|speech|voice)/i;\nconst EMBED_RE = /(embedding|embed|rerank)/i;\nconst THINK_RE = /(^o\\d|reason|thinking|-r1\\b|deepseek-reasoner)/i;\n\nexport function classifyModality(id: string): ModelModality {\n if (IMAGE_RE.test(id)) return 'image';\n if (AUDIO_RE.test(id)) return 'audio';\n if (EMBED_RE.test(id)) return 'embedding';\n return 'text';\n}\n\nexport function detectThinking(id: string): boolean {\n return THINK_RE.test(id);\n}\n\n/** Normalize an OpenAI-compatible `{ data: [{ id }] }` models response to text-only models. */\nexport function normalizeOpenAiModels(raw: unknown): DiscoveredModel[] {\n const data = (raw as { data?: Array<{ id?: string }> })?.data ?? [];\n return data\n .map((m) => m?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n .map((id) => ({ id, modality: classifyModality(id), supports_thinking: detectThinking(id) }))\n .filter((m) => m.modality === 'text');\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts` → Expected: PASS.\n\n- [ ] **Step 3: Write failing tests for `reconcile.ts`**\n\n`src/modules/ai/__tests__/reconcile.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { reconcileSelection } from '../discovery/reconcile';\n\ndescribe('reconcileSelection', () => {\n const discovered = [{ id: 'a', modality: 'text' as const }, { id: 'b', modality: 'text' as const, deprecated: true }];\n it('marks selected, deprecated, and unavailable', () => {\n const stored = [\n { id: 'a', enabled: true, order: 0, modality: 'text' as const, status: 'available' as const, source: 'discovered' as const },\n { id: 'gone', enabled: true, order: 1, modality: 'text' as const, status: 'available' as const, source: 'discovered' as const },\n ];\n const view = reconcileSelection(stored, discovered);\n expect(view.find(m => m.id === 'a')).toMatchObject({ selected: true, status: 'available' });\n expect(view.find(m => m.id === 'b')).toMatchObject({ selected: false, status: 'deprecated' });\n expect(view.find(m => m.id === 'gone')).toMatchObject({ selected: true, status: 'unavailable' });\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/reconcile.test.ts` → Expected: FAIL.\n\n- [ ] **Step 4: Implement `discovery/reconcile.ts`**\n\n```ts\nimport type { DiscoveredModel, ModelSelection, ModelStatus } from '../types';\n\nexport interface ReconciledModel extends DiscoveredModel {\n selected: boolean;\n status: ModelStatus;\n}\n\nexport function reconcileSelection(\n stored: ModelSelection[],\n discovered: DiscoveredModel[],\n): ReconciledModel[] {\n const storedById = new Map(stored.map((m) => [m.id, m]));\n const discoveredById = new Map(discovered.map((m) => [m.id, m]));\n\n const out: ReconciledModel[] = discovered.map((d) => ({\n ...d,\n selected: storedById.has(d.id),\n status: d.deprecated ? 'deprecated' : 'available',\n }));\n\n for (const s of stored) {\n if (!discoveredById.has(s.id)) {\n out.push({ id: s.id, name: s.name, modality: s.modality, supports_thinking: s.supports_thinking, selected: true, status: 'unavailable' });\n }\n }\n return out;\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/reconcile.test.ts` → Expected: PASS.\n\n- [ ] **Step 5: Add `listModels` to `IAIProvider` and implement it for OpenAI**\n\nIn `types.ts`, extend the interface:\n```ts\nexport interface ListModelsResult { supported: boolean; models: DiscoveredModel[]; }\nexport interface IAIProvider {\n readonly provider: AIProvider;\n generateContent(request: GenerateContentRequest): Promise;\n validateApiKey(apiKey: string): Promise;\n listModels(apiKey: string): Promise;\n}\n```\n\nIn `openai.provider.ts`, add the method (reuse the existing `/v1/models` fetch):\n```ts\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n// ...\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch('https://api.openai.com/v1/models', {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nAdd the `DiscoveredModel` import to `openai.provider.ts`.\n\n- [ ] **Step 6: Run the phase tests**\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts src/modules/ai/__tests__/reconcile.test.ts`\n→ Expected: PASS. (Full `pnpm typecheck` is still red until other providers gain `listModels` in\nPhase 3 — expected.)\n\n- [ ] **Step 7: Commit**\n\n```bash\ngit add src/modules/ai/discovery src/modules/ai/__tests__/normalize.test.ts src/modules/ai/__tests__/reconcile.test.ts src/modules/ai/types.ts src/modules/ai/providers/openai.provider.ts\ngit commit -m \"feat(ai): discovery normalizer + obsolescence reconcile + openai listModels\"\n```\n\n---\n\n## Phase 3 — `listModels` for the remaining built-in providers\n\n**Files:**\n- Modify: `src/modules/ai/providers/{gemini,openrouter,mistral,moonshot,xai,anthropic,deepseek,qwen,zhipu}.provider.ts`\n\nFor each provider class, add a `listModels(apiKey)` method using the provider's models endpoint and\nthe shared normalizer where the response is OpenAI-shaped.\n\n- [ ] **Step 1: OpenAI-compatible `/v1/models` providers**\n\nFor `openrouter` (`https://openrouter.ai/api/v1/models`), `mistral`\n(`https://api.mistral.ai/v1/models`), `moonshot` (`https://api.moonshot.cn/v1/models`), `xai`\n(`https://api.x.ai/v1/models`), `deepseek` (`https://api.deepseek.com/models`), `anthropic`\n(`https://api.anthropic.com/v1/models`, header `x-api-key` + `anthropic-version: 2023-06-01`), add:\n\n```ts\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n// ...\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch('', { headers: { } });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nUse `Authorization: Bearer ${apiKey}` for all except anthropic (`'x-api-key': apiKey,\n'anthropic-version': '2023-06-01'`). Anthropic and OpenRouter return `{ data: [{ id }] }`, which\n`normalizeOpenAiModels` already handles.\n\n- [ ] **Step 2: Gemini (different response shape)**\n\n`gemini.provider.ts` — endpoint `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`\nreturns `{ models: [{ name: 'models/gemini-...' }] }`. Add a small local mapper:\n```ts\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);\n if (!res.ok) return { supported: true, models: [] };\n const raw = await res.json() as { models?: Array<{ name?: string }> };\n const ids = (raw.models ?? []).map(m => (m.name ?? '').replace(/^models\\//, '')).filter(Boolean);\n const models = ids.map(id => ({ id, modality: classifyModality(id), supports_thinking: detectThinking(id) }))\n .filter(m => m.modality === 'text');\n return { supported: true, models };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nImport `classifyModality, detectThinking` from `../discovery/normalize`.\n\n- [ ] **Step 3: Providers without a usable list endpoint**\n\nFor `qwen` and `zhipu`, return unsupported so the UI falls back to manual/bulk:\n```ts\nasync listModels(): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n return { supported: false, models: [] };\n}\n```\n(If during implementation a working models endpoint is confirmed for either, implement it like\nStep 1 instead. Default to `supported: false` when unverified.)\n\n- [ ] **Step 4: Verify every provider satisfies `IAIProvider`**\n\nRun: `pnpm typecheck 2>&1 | rg \"provider\" | head`\nExpected: no \"Property 'listModels' is missing\" errors for any provider class. (Other AI files may\nstill be red until Phase 7.)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/modules/ai/providers\ngit commit -m \"feat(ai): listModels for all built-in providers (qwen/zhipu fall back to manual)\"\n```\n\n---\n\n## Phase 4 — custom OpenAI-compatible providers\n\n**Files:**\n- Create: `src/modules/ai/providers/openai-compatible.provider.ts`\n- Modify: `src/modules/ai/ai.service.ts` (factory: default branch → custom provider)\n- Create: `src/pages/api/admin/ai/custom-providers/index.ts` (GET list, POST create)\n- Create: `src/pages/api/admin/ai/custom-providers/[id].ts` (PUT update, DELETE)\n- Modify: `src/shared/validation/schemas/ai.ts` (custom provider schemas)\n- Create: `src/modules/ai/__tests__/openai-compatible.test.ts`\n\n- [ ] **Step 1: Failing test for the generic provider's normalization**\n\n`src/modules/ai/__tests__/openai-compatible.test.ts`:\n```ts\nimport { describe, it, expect, vi, afterEach } from 'vitest';\nimport { OpenAICompatibleProvider } from '../providers/openai-compatible.provider';\n\nafterEach(() => vi.restoreAllMocks());\n\ndescribe('OpenAICompatibleProvider.listModels', () => {\n it('lists text models from a custom base_url', async () => {\n vi.spyOn(globalThis, 'fetch').mockResolvedValue(\n new Response(JSON.stringify({ data: [{ id: 'custom-chat' }, { id: 'tts-x' }] }), { status: 200 }),\n );\n const p = new OpenAICompatibleProvider('my-endpoint', 'https://api.example.com/v1', 'k');\n const out = await p.listModels('k');\n expect(out.supported).toBe(true);\n expect(out.models.map(m => m.id)).toEqual(['custom-chat']);\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts` → Expected: FAIL.\n\n- [ ] **Step 2: Implement `openai-compatible.provider.ts`**\n\n```ts\nimport type { IAIProvider, GenerateContentRequest, GenerateContentResponse, DiscoveredModel } from '../types';\nimport { getSystemPrompt } from '../prompts';\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n\nexport class OpenAICompatibleProvider implements IAIProvider {\n readonly provider: string;\n constructor(provider: string, private baseUrl: string, private apiKey: string) {\n this.provider = provider;\n }\n private url(path: string) { return `${this.baseUrl.replace(/\\/$/, '')}${path}`; }\n\n async generateContent(request: GenerateContentRequest): Promise {\n const systemPrompt = getSystemPrompt(request.content_type, request.system_prompt);\n try {\n const res = await fetch(this.url('/chat/completions'), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` },\n body: JSON.stringify({\n model: request.model,\n messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: request.prompt }],\n temperature: request.temperature ?? 0.7,\n response_format: { type: 'json_object' },\n }),\n });\n const data = await res.json() as { choices?: Array<{ message?: { content?: string } }>; error?: { message?: string } };\n if (data.error) return { success: false, error: data.error.message || 'Provider error' };\n const text = data.choices?.[0]?.message?.content;\n if (!text) return { success: false, error: 'No content generated' };\n return { success: true, data: JSON.parse(text) };\n } catch (e) {\n return { success: false, error: `Generation failed: ${e instanceof Error ? e.message : 'Unknown error'}` };\n }\n }\n\n async validateApiKey(apiKey: string): Promise {\n try { return (await fetch(this.url('/models'), { headers: { Authorization: `Bearer ${apiKey}` } })).ok; }\n catch { return false; }\n }\n\n async listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch(this.url('/models'), { headers: { Authorization: `Bearer ${apiKey}` } });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch { return { supported: true, models: [] }; }\n }\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts` → Expected: PASS.\n\n- [ ] **Step 3: Open the factory to custom providers (`ai.service.ts`)**\n\nChange `createProvider` to accept an optional base_url and route unknown ids to the generic class:\n```ts\nexport function createProvider(provider: AIProvider, apiKey: string, baseUrl?: string): IAIProvider {\n switch (provider) {\n case 'gemini': return new GeminiProvider(apiKey);\n case 'openai': return new OpenAIProvider(apiKey);\n case 'anthropic': return new AnthropicProvider(apiKey);\n case 'deepseek': return new DeepSeekProvider(apiKey);\n case 'openrouter': return new OpenRouterProvider(apiKey);\n case 'qwen': return new QwenProvider(apiKey);\n case 'zhipu': return new ZhipuProvider(apiKey);\n case 'moonshot': return new MoonshotProvider(apiKey);\n case 'mistral': return new MistralProvider(apiKey);\n case 'xai': return new XAIProvider(apiKey);\n default:\n if (!baseUrl) throw new Error(`Unknown provider: ${provider}`);\n return new OpenAICompatibleProvider(provider, baseUrl, apiKey);\n }\n}\n```\nImport `OpenAICompatibleProvider`.\n\n- [ ] **Step 4: Custom-provider Zod schemas (`ai.ts`)**\n\nAdd to `src/shared/validation/schemas/ai.ts`:\n```ts\nexport const CreateCustomProviderSchema = z.object({\n id: z.string().min(1).regex(/^[a-z0-9-]+$/, 'id must be kebab-case'),\n label: z.string().min(1),\n base_url: z.string().url(),\n api_key: z.string().min(1),\n enabled: z.boolean().optional(),\n});\nexport const UpdateCustomProviderSchema = z.object({\n label: z.string().min(1).optional(),\n base_url: z.string().url().optional(),\n api_key: z.string().optional(),\n enabled: z.boolean().optional(),\n});\n```\n\n- [ ] **Step 5: Custom-provider CRUD routes**\n\n`src/pages/api/admin/ai/custom-providers/index.ts` — `GET` returns\n`stripApiKeys(settings).custom_providers`; `POST` validates `CreateCustomProviderSchema` then\n`saveAiSettings(db, { custom_providers: { [id]: { label, base_url, api_key, enabled: enabled ?? true, models: [] } } })`.\n`src/pages/api/admin/ai/custom-providers/[id].ts` — `PUT` validates `UpdateCustomProviderSchema`\nthen `saveAiSettings(db, { custom_providers: { [id]: patch } })` (deep merge keeps key/models);\n`DELETE` loads settings, removes the id from `custom_providers`, writes the full object via a\ndedicated `replaceAiSettings` (full overwrite) — add `replaceAiSettings(db, settings)` to\n`settings-store.ts` that validates + writes without merge. Both require `AuthRoles.ADMIN` via\n`extractAuthContext`/`hasRole` (follow `migrate-models.ts` auth pattern).\n\n- [ ] **Step 6: Verify + commit**\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts`\n→ Expected: PASS.\n```bash\ngit add src/modules/ai/providers/openai-compatible.provider.ts src/modules/ai/ai.service.ts src/modules/ai/settings-store.ts src/shared/validation/schemas/ai.ts src/pages/api/admin/ai/custom-providers src/modules/ai/__tests__/openai-compatible.test.ts\ngit commit -m \"feat(ai): OpenAI-compatible custom providers + CRUD\"\n```\n\n---\n\n## Phase 5 — discovery + settings + providers + generate endpoints (snake_case)\n\n**Files:**\n- Create: `src/pages/api/admin/ai/models/[provider]/discover.ts`\n- Modify: `src/pages/api/admin/ai/settings.ts`, `providers.ts`, `generate.ts`,\n `models/[provider].ts`, `models/[provider]/[modelId].ts`\n- Delete: `src/pages/api/admin/ai/migrate-models.ts`\n- Modify: `src/shared/validation/schemas/ai.ts` (snake_case settings/generate schemas)\n- Modify: `src/modules/ai/ai.service.ts` (`getModelsForProvider`, `generateContent`,\n `getConfiguredProviders`, resolution helper)\n\n- [ ] **Step 1: Rewrite `ai.ts` request schemas to snake_case**\n\nUpdate `GenerateSchema`, `UpdateSettingsSchema`, `AddModelSchema`, `UpdateModelSchema`,\n`ProviderModelParam`, `ValidateApiKeySchema` to snake_case keys:\n```ts\nexport const GenerateSchema = z.object({\n prompt: z.string().min(3).max(10000),\n content_type: z.enum(['recipe', 'article', 'roundup']),\n provider: z.string().optional(),\n model: z.string().min(1).optional(),\n temperature: z.number().min(0).max(2).optional(),\n reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),\n}).passthrough();\n\nexport const UpdateSettingsSchema = z.object({\n default_provider: z.string().optional(),\n default_model: z.string().optional(),\n temperature: z.number().min(0).max(1).optional(),\n system_prompt: z.string().optional(),\n providers: z.record(z.string(), z.object({\n api_key: z.string().optional(),\n enabled: z.boolean().optional(),\n models: z.array(z.any()).optional(),\n })).optional(),\n}).passthrough();\n\nexport const AddModelSchema = z.object({\n id: z.string().min(1),\n name: z.string().min(1),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n}).passthrough();\n\nexport const UpdateModelSchema = z.object({\n name: z.string().min(1).optional(),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n deprecated: z.boolean().optional(),\n enabled: z.boolean().optional(),\n}).passthrough();\n\nexport const ProviderModelParam = z.object({ provider: z.string().min(1), model_id: z.string().min(1) });\nexport const ValidateApiKeySchema = z.object({ provider: z.string().min(1), api_key: z.string().min(1) });\n```\n(`ProviderEnum` is replaced by `z.string()` since custom providers exist; validity is checked\nagainst settings at runtime.)\n\n- [ ] **Step 2: Discovery endpoint**\n\n`src/pages/api/admin/ai/models/[provider]/discover.ts` (`GET`, `AuthRoles.ADMIN`):\n```ts\n// resolve provider config (built-in providers[p] or custom_providers[p]) from getAiSettings\n// const cfg = settings.providers[provider] ?? settings.custom_providers[provider];\n// if (!cfg?.api_key) -> 400 \"No API key configured for \"\n// const impl = createProvider(provider, cfg.api_key, (cfg as CustomProviderConfig).base_url);\n// const { supported, models } = await impl.listModels(cfg.api_key);\n// const view = reconcileSelection(cfg.models ?? [], models);\n// return formatSuccessResponse({ supported, models: view });\n```\nUse `formatSuccessResponse`/`formatErrorResponse` and the `validateParams`-style param read used by\nsibling routes.\n\n- [ ] **Step 3: `settings.ts` GET (masked) / PUT (deep merge)**\n\nGET: `formatSuccessResponse(stripApiKeys(await getAiSettings(db)))`.\nPUT: `validateBody(request, UpdateSettingsSchema)` → `saveAiSettings(db, body)` → return\n`stripApiKeys(await getAiSettings(db))`.\nPOST (validate key): `createProvider(provider, api_key, base_url?).validateApiKey(api_key)`.\nRemove every camelCase field; require `AuthRoles.ADMIN`.\n\n- [ ] **Step 4: `providers.ts`, `generate.ts`, model CRUD to snake_case + selection**\n\n`providers.ts` GET → `{ configured_providers, available_models, provider_info, defaults }` where\n`available_models[p] = getModelsForProvider(db, p)` (stored selection, enabled only). Strip keys.\n`generate.ts` → read `GenerateSchema`, call `generateContent(db, { ...body })`, snake_case response.\n`models/[provider].ts` (POST add / GET list) and `models/[provider]/[modelId].ts` (PUT/DELETE) →\noperate on `providers[p].models` (or `custom_providers[p].models`) via `saveAiSettings`, snake_case\nfields, `source: 'manual'` for manually added models.\n\n- [ ] **Step 5: `ai.service.ts` resolution + generate**\n\n```ts\nexport async function getModelsForProvider(db: D1Database, provider: AIProvider): Promise {\n const settings = await getAiSettings(db);\n const cfg = settings.providers[provider as BuiltInProvider] ?? settings.custom_providers[provider];\n return (cfg?.models ?? []).filter(m => m.enabled && m.status !== 'unavailable');\n}\n\nexport async function generateContent(db: D1Database, request: GenerateContentRequest): Promise {\n const settings = await getAiSettings(db);\n const provider = request.provider || settings.default_provider;\n const cfg = settings.providers[provider as BuiltInProvider] ?? settings.custom_providers[provider];\n if (!cfg?.api_key) return { success: false, error: `No API key configured for provider: ${provider}` };\n if (!cfg.enabled) return { success: false, error: `Provider is disabled: ${provider}` };\n const impl = createProvider(provider, cfg.api_key, (cfg as CustomProviderConfig).base_url);\n const model = request.model || (provider === settings.default_provider ? settings.default_model : request.model);\n return impl.generateContent({\n ...request,\n model: model || request.model,\n temperature: request.temperature ?? settings.temperature,\n system_prompt: request.system_prompt || settings.system_prompt,\n });\n}\n```\nUpdate `getConfiguredProviders` to iterate `providers` + `custom_providers` reading `api_key`/`enabled`.\n\n- [ ] **Step 6: Delete the obsolete migration endpoint**\n\n```bash\ngit rm src/pages/api/admin/ai/migrate-models.ts\n```\nThen `rg -n \"migrate-models|AVAILABLE_MODELS\" src` and remove every remaining reference.\n\n- [ ] **Step 7: Verify + commit**\n\nRun: `pnpm test && pnpm check:boundaries`\nExpected: PASS / \"Boundary check passed.\" (`pnpm typecheck` may still be red until admin UI in\nPhase 7.)\n```bash\ngit add -A\ngit commit -m \"feat(ai): discovery + snake_case settings/providers/generate endpoints; drop migrate-models\"\n```\n\n---\n\n## Phase 6 — thinking / reasoning mapping at the provider boundary\n\n**Files:**\n- Modify: providers that support reasoning — `openai`, `anthropic`, `deepseek`, `openai-compatible`\n- Modify: `src/modules/ai/__tests__/...` (one mapping test)\n\n- [ ] **Step 1: Map `reasoning_effort` per provider at the fetch boundary**\n\nIn `openai.provider.ts` and `openai-compatible.provider.ts`, when `request.reasoning_effort` is set,\nadd `reasoning_effort: request.reasoning_effort` to the request body. In `anthropic.provider.ts`,\nmap to `thinking: { type: 'enabled', budget_tokens: }`. In\n`deepseek.provider.ts`, no body change is required (reasoner is model-selected) — leave a comment.\nProviders without support ignore the field (no change).\n\n- [ ] **Step 2: Test the mapping (openai body includes reasoning_effort)**\n\nAdd to an existing/new provider test that mocks `fetch` and asserts the request body passed to\n`fetch` contains `reasoning_effort: 'high'` when set. Run that test → Expected: PASS.\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/modules/ai/providers src/modules/ai/__tests__\ngit commit -m \"feat(ai): map reasoning_effort to provider-native thinking params\"\n```\n\n---\n\n## Phase 7 — admin UI: snake_case + discovery UX + has_api_key\n\n**Files:**\n- Modify: `src/admin/services/api.ts` (AI client methods)\n- Modify: `src/admin/components/ModelManager.tsx`, `BulkImportModels.tsx`\n- Modify: `src/admin/features/settings/pages/tabs/AISettings.tsx`,\n `src/admin/components/BlockEditor/components/AISettings.tsx`,\n `src/admin/features/settings/pages/Settings.tsx`\n\n- [ ] **Step 1: `api.ts` AI client methods → snake_case + discover + custom providers**\n\nAdd/repoint methods: `aiSettings.get/update`, `aiProviders.get`, `aiModels.discover(provider)`,\n`aiModels.add/update/remove`, `aiCustomProviders.list/create/update/remove`, `aiGenerate(payload)`.\nAll payloads/reads use snake_case (`content_type`, `reasoning_effort`, `default_provider`,\n`has_api_key`, `context_window`, `max_tokens`).\n\n- [ ] **Step 2: ModelManager — discovery checklist**\n\nRework `ManagedModel` to snake_case (`context_window`, `max_tokens`, `enabled`, `deprecated`,\n`status`). Add a \"Fetch latest models\" button calling `aiModels.discover(provider)`; render the\nreturned `models` as a checklist with `status` badges (`unavailable`/`deprecated`); ticking a model\nsaves it into the selection via `aiSettings.update`/`aiModels.add`. Keep manual add + bulk import\nvisible as fallback (and shown automatically when `discover` returns `supported: false`).\n\n- [ ] **Step 3: BulkImportModels + both AISettings tabs + Settings.tsx**\n\nRename all AI data-key reads/writes to snake_case (`context_window`, `max_tokens`, `api_key`→write\nonly, render `has_api_key`/`api_key_masked` instead of the raw key, `default_provider`,\n`default_model`, `system_prompt`, `reasoning_effort` selector shown when the chosen model has\n`supports_thinking`). Add a custom-provider add/edit form (id/label/base_url/api_key) backed by\n`aiCustomProviders`.\n\n- [ ] **Step 4: Verify full green**\n\nRun: `pnpm typecheck && pnpm test && pnpm check:boundaries`\nExpected: all green (this is the phase where typecheck returns fully clean).\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/admin\ngit commit -m \"feat(ai): snake_case admin AI UI + model discovery UX + write-only keys\"\n```\n\n---\n\n## Phase 8 — one-shot stored-blob data migration\n\n**Files:**\n- Create: `scripts/migrate-ai-settings.mts`\n- Create: `src/modules/ai/__tests__/migrate-ai-settings.test.ts`\n\n- [ ] **Step 1: Failing test for the pure transform**\n\n`src/modules/ai/__tests__/migrate-ai-settings.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { migrateAiSettingsBlob } from '../../../scripts/migrate-ai-settings.mts';\n\ndescribe('migrateAiSettingsBlob', () => {\n it('converts legacy camelCase blob to snake_case selection shape', () => {\n const legacy = {\n defaultProvider: 'openai', defaultModel: 'gpt-4o', temperature: 0.7, systemPrompt: 'sp',\n providers: { openai: { apiKey: 'sk-1', enabled: true,\n availableModels: [{ id: 'gpt-4o', name: 'GPT-4o', contextWindow: 131072, maxTokens: 65536, enabled: true }] } },\n };\n const out = migrateAiSettingsBlob(legacy);\n expect(out.default_provider).toBe('openai');\n expect(out.system_prompt).toBe('sp');\n expect(out.providers.openai.api_key).toBe('sk-1');\n expect(out.providers.openai.models[0]).toMatchObject({\n id: 'gpt-4o', context_window: 131072, max_tokens: 65536, modality: 'text',\n status: 'available', source: 'discovered', enabled: true,\n });\n expect(out.custom_providers).toEqual({});\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/migrate-ai-settings.test.ts` → Expected: FAIL.\n\n- [ ] **Step 2: Implement `scripts/migrate-ai-settings.mts`**\n\nExport a pure `migrateAiSettingsBlob(legacy)` (camelCase → snake_case, `availableModels` → `models`\nwith `modality:'text'`, `status:'available'`, `source:'discovered'`, `order` by index) plus a\n`main()` that opens the local D1 sqlite (reuse the `findDb` pattern from\n`scripts/migrate-credit-avatar-r2key.mts`), reads `site_settings.value WHERE key='ai_settings'`,\nprints a dry-run diff, and writes the transformed blob under `--apply`. Validate the result with\n`AiSettingsSchema` before writing.\n\nRun: `pnpm test src/modules/ai/__tests__/migrate-ai-settings.test.ts` → Expected: PASS.\n\n- [ ] **Step 3: Dry-run against local D1**\n\nRun: `node_modules/.bin/tsx scripts/migrate-ai-settings.mts`\nExpected: prints the would-be snake_case blob; no write. (If no `ai_settings` row exists locally,\nit prints \"no ai_settings row — nothing to migrate\".)\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add scripts/migrate-ai-settings.mts src/modules/ai/__tests__/migrate-ai-settings.test.ts\ngit commit -m \"chore(ai): one-shot migration of stored ai_settings to snake_case selection shape\"\n```\n\n---\n\n## Phase 9 — finalize & close AI portion of #3\n\n**Files:**\n- Modify: `docs/NAMING_CONTRACT.md` (only if it still implies AI is pending)\n- Modify: `.hermes/plans/2026-06-03_contract-audit-report.md` (local record)\n\n- [ ] **Step 1: Full regression gate**\n\nRun: `pnpm typecheck && pnpm test && pnpm check:boundaries`\nExpected: all green.\n\n- [ ] **Step 2: Contract audit — no AI camelCase regressions**\n\nRun: `rg -n \"\\b(defaultProvider|defaultModel|systemPrompt|apiKey|availableModels|contextWindow|maxTokens|modelId)\\b\" src/modules/ai src/pages/api/admin/ai src/admin --glob '!**/*.test.*'`\nExpected: matches only inside provider classes at the external fetch boundary (allowed exception)\nand local variables — no app-owned data-shape keys. Then\n`node scripts/local-contract-audit.mjs --summary` → no new camelCase violation.\n\n- [ ] **Step 3: Commit (if docs changed)**\n\n```bash\ngit add docs/NAMING_CONTRACT.md\ngit commit -m \"docs(naming): AI subsystem migrated to snake_case (audit #3)\"\n```\n\n---\n\n## Self-review notes\n\n- **Spec coverage:** stored shape (P0/P1/P5), discovery `listModels` (P2/P3), discover endpoint\n (P5), custom providers (P4), safe persistence + Zod (P1), key masking (P1/P5), obsolescence\n reconcile (P2 + P5 resolution filter), thinking (P6), modality filter (P2 normalizer), snake_case\n E2E (P0/P5/P7), data migration (P8), cleanup of AVAILABLE_MODELS + migrate-models (P5). ✓\n- **No placeholders:** pure-logic units ship full code + tests; mechanical endpoint/UI tasks give\n exact files, exact snake_case key lists, and a shared normalizer (DRY) rather than repeated\n provider code. ✓\n- **Type consistency:** `AiSettings`, `ModelSelection`, `DiscoveredModel`, `ListModelsResult`,\n `reconcileSelection`, `mergeAiSettings`, `getAiSettings`/`saveAiSettings`/`replaceAiSettings`,\n `stripApiKeys`/`maskApiKey`, `normalizeOpenAiModels`/`classifyModality`/`detectThinking` are used\n with consistent names across phases. ✓\n- **Typecheck phasing:** Phases 1, 2, 3, 8 are green in isolation; Phases 0, 4–7 may leave global\n typecheck red until Phase 7 (documented in the Phase 0 note); every phase's own unit tests pass.\n"}, "a781111834f7406feda96cb9bb9e090da9ccf4ca": {"t": 1785765912.6981046, "n": 47112, "h": "3e564c90753eb3db", "c": "# AI Model Management Redesign Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Replace the hardcoded AI model catalog with live per-provider discovery, store only the admin's curated snake_case selection with safe validated persistence and write-only keys, add OpenAI-compatible custom providers, model-obsolescence reconciliation, and a thinking/reasoning control — closing the AI portion of Contract Audit #3.\n\n**Architecture:** Pure-logic units (snake_case types, Zod stored-blob schema, deep-merge persistence, key masking, model normalization/modality classification, obsolescence reconciliation) are built test-first. Provider `listModels` implementations share one OpenAI-style normalizer. API routes, the admin UI, and the client are migrated to snake_case and wired to discovery. A one-shot script migrates the existing stored blob.\n\n**Tech Stack:** Cloudflare D1 (`site_settings`), Zod, Astro API routes, React admin SPA, Vitest, `node:sqlite` for the migration script.\n\n**Spec:** `docs/superpowers/specs/2026-06-04-ai-model-management-redesign-design.md`\n\n---\n\n## Conventions (read once)\n\n- **snake_case** for all app-owned data keys (stored blob, our API envelopes, admin props, types\n that model them). Local variables/functions/React state may stay camelCase (`NAMING_CONTRACT`\n L21). External provider HTTP request/response field names stay as the provider names **only\n inside each provider class** at the fetch boundary (Allowed Exception).\n- **TDD** for pure units (types are compile-time; logic units get a failing test first). Mechanical\n snake_case renames and UI wiring are verified by `pnpm test && pnpm check:boundaries &&\n pnpm typecheck`.\n- **Run between every task:** `pnpm test && pnpm check:boundaries && pnpm typecheck`. All must be\n green before the task's commit.\n- Test files live next to the code under `__tests__/` per existing convention\n (e.g. `src/modules/ai/__tests__/...`).\n\n## File structure (created / modified)\n\n- `src/modules/ai/types.ts` — replace AISettings/AIModel/AVAILABLE_MODELS with snake_case\n `AiSettings`, `ModelSelection`, `DiscoveredModel`, capability enums; keep `ALL_PROVIDERS`,\n `PROVIDER_INFO`, `DEFAULT_AI_SETTINGS` (snake).\n- `src/modules/ai/settings-schema.ts` (new) — Zod schema for the stored blob.\n- `src/modules/ai/settings-store.ts` (new) — `getAiSettings` / `saveAiSettings` (deep merge +\n validate) extracted from `ai.service.ts`.\n- `src/modules/ai/api-key.ts` (new) — `maskApiKey`, `stripApiKeys`.\n- `src/modules/ai/discovery/normalize.ts` (new) — `normalizeOpenAiModels`, `classifyModality`,\n `detectThinking`.\n- `src/modules/ai/discovery/reconcile.ts` (new) — `reconcileSelection`.\n- `src/modules/ai/providers/openai-compatible.provider.ts` (new) — generic custom provider.\n- `src/modules/ai/providers/*.provider.ts` — add `listModels`; snake_case app-owned fields.\n- `src/modules/ai/ai.service.ts` — discovery resolution, generate with `reasoning_effort`,\n open-ended factory.\n- `src/shared/validation/schemas/ai.ts` — snake_case Zod schemas + `reasoning_effort`.\n- `src/pages/api/admin/ai/models/[provider]/discover.ts` (new) — discovery endpoint.\n- `src/pages/api/admin/ai/settings.ts`, `providers.ts`, `generate.ts`,\n `models/[provider]/[modelId].ts`, `models/[provider].ts` — snake_case + masking + custom CRUD.\n- `src/pages/api/admin/ai/migrate-models.ts` — **delete**.\n- `src/pages/api/admin/ai/custom-providers/*` (new) — custom provider CRUD.\n- `src/admin/components/ModelManager.tsx`, `BulkImportModels.tsx`,\n `src/admin/features/settings/pages/tabs/AISettings.tsx`,\n `src/admin/components/BlockEditor/components/AISettings.tsx`,\n `src/admin/features/settings/pages/Settings.tsx`, `src/admin/services/api.ts` — snake_case +\n discovery UX + `has_api_key`.\n- `scripts/migrate-ai-settings.mts` (new) — one-shot stored-blob migration.\n\n---\n\n## Phase 0 — snake_case types & defaults\n\n**Files:**\n- Modify: `src/modules/ai/types.ts`\n\n- [ ] **Step 1: Replace the data-shape interfaces with snake_case**\n\nReplace `AIModel`, `AISettings`, `DEFAULT_AI_SETTINGS`, and remove `AVAILABLE_MODELS`. Keep\n`AIProvider`, `ALL_PROVIDERS`, `PROVIDER_INFO`, `IAIProvider`, `GenerateContentRequest`,\n`GeneratedRecipe`. New definitions:\n\n```ts\n/** Built-in provider id, or a custom OpenAI-compatible provider id (slug). */\nexport type BuiltInProvider =\n | 'gemini' | 'openai' | 'anthropic' | 'deepseek' | 'openrouter'\n | 'qwen' | 'zhipu' | 'moonshot' | 'mistral' | 'xai';\nexport type AIProvider = BuiltInProvider | (string & {});\n\nexport type ModelModality = 'text' | 'image' | 'audio' | 'embedding' | 'other';\nexport type ModelStatus = 'available' | 'unavailable' | 'deprecated';\n\n/** A model the provider currently offers (discovery output). */\nexport interface DiscoveredModel {\n id: string;\n name?: string;\n context_window?: number;\n max_tokens?: number;\n modality: ModelModality;\n supports_thinking?: boolean;\n deprecated?: boolean;\n}\n\n/** A model the admin has curated into the stored selection. */\nexport interface ModelSelection {\n id: string;\n name?: string;\n context_window?: number;\n max_tokens?: number;\n modality: ModelModality;\n supports_thinking?: boolean;\n enabled: boolean;\n order: number;\n deprecated?: boolean;\n status: ModelStatus;\n source: 'discovered' | 'manual';\n}\n\nexport interface ProviderConfig {\n enabled: boolean;\n api_key: string;\n models: ModelSelection[];\n}\n\nexport interface CustomProviderConfig extends ProviderConfig {\n label: string;\n base_url: string;\n}\n\nexport interface AiSettings {\n default_provider: AIProvider;\n default_model: string;\n temperature: number;\n system_prompt: string;\n providers: Partial>;\n custom_providers: Record;\n}\n\nexport const DEFAULT_AI_SETTINGS: AiSettings = {\n default_provider: 'gemini',\n default_model: 'gemini-3-flash-preview',\n temperature: 0.7,\n system_prompt: `You are an expert culinary content creator. Generate structured recipe and article content in JSON format.\nAlways respond with valid JSON that matches the expected schema.\nBe creative, detailed, and provide practical cooking tips.`,\n providers: {},\n custom_providers: {},\n};\n```\n\nUpdate `GenerateContentRequest` to snake_case app-owned fields and add the reasoning control:\n\n```ts\nexport interface GenerateContentRequest {\n prompt: string;\n content_type: 'recipe' | 'article' | 'roundup';\n provider: AIProvider;\n model: string;\n temperature?: number;\n system_prompt?: string;\n reasoning_effort?: 'low' | 'medium' | 'high';\n}\n```\n\n- [ ] **Step 2: Typecheck (expect downstream breakages — they are fixed in later phases)**\n\nRun: `pnpm typecheck 2>&1 | head -40`\nExpected: errors only in AI files that consume the old names (ai.service, providers, endpoints,\nadmin). These are addressed in Phases 1–7. Do NOT fix unrelated files here.\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/modules/ai/types.ts\ngit commit -m \"refactor(ai): snake_case AiSettings + add discovery/selection model types\"\n```\n\n> Note for the executor: Phases 0–7 form one compile-coherent unit. Commit per phase, but\n> `pnpm typecheck` only returns fully green at the end of Phase 7. Phases that CAN be green in\n> isolation (1, 2, 3, 8) state so; Phases 0, 4–7 may leave typecheck red until Phase 7. Each\n> phase's own unit tests must pass.\n\n---\n\n## Phase 1 — safe persistence, validation, key masking\n\n**Files:**\n- Create: `src/modules/ai/settings-schema.ts`\n- Create: `src/modules/ai/api-key.ts`\n- Create: `src/modules/ai/settings-store.ts`\n- Create: `src/modules/ai/__tests__/settings-store.test.ts`\n- Create: `src/modules/ai/__tests__/api-key.test.ts`\n\n- [ ] **Step 1: Write failing tests for api-key masking**\n\n`src/modules/ai/__tests__/api-key.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { maskApiKey, stripApiKeys } from '../api-key';\n\ndescribe('maskApiKey', () => {\n it('masks all but the last 4 chars', () => {\n expect(maskApiKey('sk-abcdef1234')).toBe('••••1234');\n });\n it('returns empty string for empty/undefined', () => {\n expect(maskApiKey('')).toBe('');\n expect(maskApiKey(undefined)).toBe('');\n });\n});\n\ndescribe('stripApiKeys', () => {\n it('replaces api_key with has_api_key + masked across providers and custom_providers', () => {\n const out = stripApiKeys({\n default_provider: 'openai', default_model: 'm', temperature: 0.7, system_prompt: 's',\n providers: { openai: { enabled: true, api_key: 'sk-xxxx9999', models: [] } },\n custom_providers: { c1: { label: 'C', base_url: 'u', enabled: true, api_key: 'k-1111', models: [] } },\n });\n expect(out.providers.openai).toMatchObject({ has_api_key: true, api_key_masked: '••••9999' });\n expect('api_key' in out.providers.openai).toBe(false);\n expect(out.custom_providers.c1).toMatchObject({ has_api_key: true, api_key_masked: '••••1111' });\n expect('api_key' in out.custom_providers.c1).toBe(false);\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/api-key.test.ts` → Expected: FAIL (module missing).\n\n- [ ] **Step 2: Implement `api-key.ts`**\n\n```ts\nimport type { AiSettings } from './types';\n\nexport function maskApiKey(key: string | undefined): string {\n if (!key) return '';\n return `••••${key.slice(-4)}`;\n}\n\ntype StrippedProvider = { enabled: boolean; models: unknown[]; has_api_key: boolean; api_key_masked: string };\n\nfunction strip(cfg: T): Omit & { has_api_key: boolean; api_key_masked: string } {\n const { api_key, ...rest } = cfg;\n return { ...rest, has_api_key: Boolean(api_key), api_key_masked: maskApiKey(api_key) };\n}\n\nexport function stripApiKeys(settings: AiSettings) {\n return {\n ...settings,\n providers: Object.fromEntries(\n Object.entries(settings.providers).map(([k, v]) => [k, strip(v!)]),\n ),\n custom_providers: Object.fromEntries(\n Object.entries(settings.custom_providers).map(([k, v]) => [k, strip(v)]),\n ),\n };\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/api-key.test.ts` → Expected: PASS.\n\n- [ ] **Step 3: Implement the Zod stored-blob schema (`settings-schema.ts`)**\n\n```ts\nimport { z } from 'zod';\n\nconst ModelSelectionSchema = z.object({\n id: z.string().min(1),\n name: z.string().optional(),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n modality: z.enum(['text', 'image', 'audio', 'embedding', 'other']).default('text'),\n supports_thinking: z.boolean().optional(),\n enabled: z.boolean().default(true),\n order: z.number().int().nonnegative().default(0),\n deprecated: z.boolean().optional(),\n status: z.enum(['available', 'unavailable', 'deprecated']).default('available'),\n source: z.enum(['discovered', 'manual']).default('discovered'),\n});\n\nconst ProviderConfigSchema = z.object({\n enabled: z.boolean().default(false),\n api_key: z.string().default(''),\n models: z.array(ModelSelectionSchema).default([]),\n});\n\nconst CustomProviderConfigSchema = ProviderConfigSchema.extend({\n label: z.string().min(1),\n base_url: z.string().url(),\n});\n\nexport const AiSettingsSchema = z.object({\n default_provider: z.string().min(1),\n default_model: z.string().default(''),\n temperature: z.number().min(0).max(2).default(0.7),\n system_prompt: z.string().default(''),\n providers: z.record(z.string(), ProviderConfigSchema).default({}),\n custom_providers: z.record(z.string(), CustomProviderConfigSchema).default({}),\n});\n```\n\n- [ ] **Step 4: Write failing tests for the store (deep merge + validate + fallback)**\n\n`src/modules/ai/__tests__/settings-store.test.ts`:\n```ts\nimport { describe, it, expect, vi } from 'vitest';\nimport { mergeAiSettings } from '../settings-store';\nimport { DEFAULT_AI_SETTINGS } from '../types';\n\ndescribe('mergeAiSettings', () => {\n it('deep-merges one provider without dropping others', () => {\n const current = {\n ...DEFAULT_AI_SETTINGS,\n providers: {\n openai: { enabled: true, api_key: 'sk-1', models: [] },\n gemini: { enabled: true, api_key: 'g-1', models: [] },\n },\n };\n const merged = mergeAiSettings(current, { providers: { openai: { enabled: false } } });\n expect(merged.providers.gemini).toEqual({ enabled: true, api_key: 'g-1', models: [] });\n expect(merged.providers.openai).toMatchObject({ enabled: false, api_key: 'sk-1' });\n });\n\n it('keeps existing api_key when the patch omits it', () => {\n const current = { ...DEFAULT_AI_SETTINGS, providers: { openai: { enabled: true, api_key: 'sk-keep', models: [] } } };\n const merged = mergeAiSettings(current, { providers: { openai: { models: [] } } });\n expect(merged.providers.openai.api_key).toBe('sk-keep');\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/settings-store.test.ts` → Expected: FAIL.\n\n- [ ] **Step 5: Implement `settings-store.ts`**\n\n```ts\nimport type { D1Database } from '@cloudflare/workers-types';\nimport type { AiSettings } from './types';\nimport { DEFAULT_AI_SETTINGS } from './types';\nimport { AiSettingsSchema } from './settings-schema';\n\nconst AI_SETTINGS_KEY = 'ai_settings';\n\nfunction mergeProviderMap>(current: T, patch: Partial | undefined): T {\n if (!patch) return current;\n const out: Record = { ...current };\n for (const [id, cfg] of Object.entries(patch)) {\n out[id] = { ...(current[id] ?? {}), ...(cfg as object) };\n }\n return out as T;\n}\n\nexport function mergeAiSettings(current: AiSettings, patch: Partial): AiSettings {\n return {\n ...current,\n ...patch,\n providers: mergeProviderMap(current.providers, patch.providers),\n custom_providers: mergeProviderMap(current.custom_providers, patch.custom_providers),\n };\n}\n\nexport async function getAiSettings(db: D1Database): Promise {\n try {\n const row = await db.prepare('SELECT value FROM site_settings WHERE key = ?')\n .bind(AI_SETTINGS_KEY).first<{ value: string }>();\n if (row?.value) {\n const parsed = AiSettingsSchema.safeParse(JSON.parse(row.value));\n if (parsed.success) return parsed.data as AiSettings;\n console.error('Invalid ai_settings blob, using defaults:', parsed.error.issues);\n }\n } catch (e) {\n console.error('Failed to load AI settings:', e);\n }\n return DEFAULT_AI_SETTINGS;\n}\n\nexport async function saveAiSettings(db: D1Database, patch: Partial): Promise {\n try {\n const merged = mergeAiSettings(await getAiSettings(db), patch);\n const validated = AiSettingsSchema.parse(merged);\n await db.prepare(`\n INSERT INTO site_settings (key, value, category, type, updated_at)\n VALUES (?, ?, 'ai', 'json', CURRENT_TIMESTAMP)\n ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP\n `).bind(AI_SETTINGS_KEY, JSON.stringify(validated)).run();\n return true;\n } catch (e) {\n console.error('Failed to save AI settings:', e);\n return false;\n }\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/settings-store.test.ts src/modules/ai/__tests__/api-key.test.ts`\n→ Expected: PASS.\n\n- [ ] **Step 6: Commit**\n\n```bash\ngit add src/modules/ai/settings-schema.ts src/modules/ai/api-key.ts src/modules/ai/settings-store.ts src/modules/ai/__tests__/settings-store.test.ts src/modules/ai/__tests__/api-key.test.ts\ngit commit -m \"feat(ai): validated deep-merge settings store + write-only key masking\"\n```\n\n---\n\n## Phase 2 — discovery core (normalize, classify, reconcile) + openai listModels\n\n**Files:**\n- Create: `src/modules/ai/discovery/normalize.ts`\n- Create: `src/modules/ai/discovery/reconcile.ts`\n- Create: `src/modules/ai/__tests__/normalize.test.ts`\n- Create: `src/modules/ai/__tests__/reconcile.test.ts`\n- Modify: `src/modules/ai/types.ts` (add `listModels` to `IAIProvider`)\n- Modify: `src/modules/ai/providers/openai.provider.ts`\n\n- [ ] **Step 1: Write failing tests for `normalize.ts`**\n\n`src/modules/ai/__tests__/normalize.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { classifyModality, detectThinking, normalizeOpenAiModels } from '../discovery/normalize';\n\ndescribe('classifyModality', () => {\n it('classifies known non-text ids', () => {\n expect(classifyModality('dall-e-3')).toBe('image');\n expect(classifyModality('gpt-image-1')).toBe('image');\n expect(classifyModality('tts-1')).toBe('audio');\n expect(classifyModality('text-embedding-3-large')).toBe('embedding');\n expect(classifyModality('gpt-5.1-chat-latest')).toBe('text');\n });\n});\n\ndescribe('detectThinking', () => {\n it('flags reasoning model ids', () => {\n expect(detectThinking('o3-mini')).toBe(true);\n expect(detectThinking('deepseek-reasoner')).toBe(true);\n expect(detectThinking('gpt-4o')).toBe(false);\n });\n});\n\ndescribe('normalizeOpenAiModels', () => {\n it('maps an OpenAI /v1/models list to text-only DiscoveredModel[]', () => {\n const raw = { data: [{ id: 'gpt-5.1-chat-latest' }, { id: 'dall-e-3' }, { id: 'tts-1' }] };\n const models = normalizeOpenAiModels(raw);\n expect(models.map(m => m.id)).toEqual(['gpt-5.1-chat-latest']);\n expect(models[0]).toMatchObject({ id: 'gpt-5.1-chat-latest', modality: 'text', supports_thinking: false });\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts` → Expected: FAIL.\n\n- [ ] **Step 2: Implement `discovery/normalize.ts`**\n\n```ts\nimport type { DiscoveredModel, ModelModality } from '../types';\n\nconst IMAGE_RE = /(dall-e|gpt-image|imagen|stable-diffusion|flux)/i;\nconst AUDIO_RE = /(tts|whisper|audio|speech|voice)/i;\nconst EMBED_RE = /(embedding|embed|rerank)/i;\nconst THINK_RE = /(^o\\d|reason|thinking|-r1\\b|deepseek-reasoner)/i;\n\nexport function classifyModality(id: string): ModelModality {\n if (IMAGE_RE.test(id)) return 'image';\n if (AUDIO_RE.test(id)) return 'audio';\n if (EMBED_RE.test(id)) return 'embedding';\n return 'text';\n}\n\nexport function detectThinking(id: string): boolean {\n return THINK_RE.test(id);\n}\n\n/** Normalize an OpenAI-compatible `{ data: [{ id }] }` models response to text-only models. */\nexport function normalizeOpenAiModels(raw: unknown): DiscoveredModel[] {\n const data = (raw as { data?: Array<{ id?: string }> })?.data ?? [];\n return data\n .map((m) => m?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n .map((id) => ({ id, modality: classifyModality(id), supports_thinking: detectThinking(id) }))\n .filter((m) => m.modality === 'text');\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts` → Expected: PASS.\n\n- [ ] **Step 3: Write failing tests for `reconcile.ts`**\n\n`src/modules/ai/__tests__/reconcile.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { reconcileSelection } from '../discovery/reconcile';\n\ndescribe('reconcileSelection', () => {\n const discovered = [{ id: 'a', modality: 'text' as const }, { id: 'b', modality: 'text' as const, deprecated: true }];\n it('marks selected, deprecated, and unavailable', () => {\n const stored = [\n { id: 'a', enabled: true, order: 0, modality: 'text' as const, status: 'available' as const, source: 'discovered' as const },\n { id: 'gone', enabled: true, order: 1, modality: 'text' as const, status: 'available' as const, source: 'discovered' as const },\n ];\n const view = reconcileSelection(stored, discovered);\n expect(view.find(m => m.id === 'a')).toMatchObject({ selected: true, status: 'available' });\n expect(view.find(m => m.id === 'b')).toMatchObject({ selected: false, status: 'deprecated' });\n expect(view.find(m => m.id === 'gone')).toMatchObject({ selected: true, status: 'unavailable' });\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/reconcile.test.ts` → Expected: FAIL.\n\n- [ ] **Step 4: Implement `discovery/reconcile.ts`**\n\n```ts\nimport type { DiscoveredModel, ModelSelection, ModelStatus } from '../types';\n\nexport interface ReconciledModel extends DiscoveredModel {\n selected: boolean;\n status: ModelStatus;\n}\n\nexport function reconcileSelection(\n stored: ModelSelection[],\n discovered: DiscoveredModel[],\n): ReconciledModel[] {\n const storedById = new Map(stored.map((m) => [m.id, m]));\n const discoveredById = new Map(discovered.map((m) => [m.id, m]));\n\n const out: ReconciledModel[] = discovered.map((d) => ({\n ...d,\n selected: storedById.has(d.id),\n status: d.deprecated ? 'deprecated' : 'available',\n }));\n\n for (const s of stored) {\n if (!discoveredById.has(s.id)) {\n out.push({ id: s.id, name: s.name, modality: s.modality, supports_thinking: s.supports_thinking, selected: true, status: 'unavailable' });\n }\n }\n return out;\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/reconcile.test.ts` → Expected: PASS.\n\n- [ ] **Step 5: Add `listModels` to `IAIProvider` and implement it for OpenAI**\n\nIn `types.ts`, extend the interface:\n```ts\nexport interface ListModelsResult { supported: boolean; models: DiscoveredModel[]; }\nexport interface IAIProvider {\n readonly provider: AIProvider;\n generateContent(request: GenerateContentRequest): Promise;\n validateApiKey(apiKey: string): Promise;\n listModels(apiKey: string): Promise;\n}\n```\n\nIn `openai.provider.ts`, add the method (reuse the existing `/v1/models` fetch):\n```ts\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n// ...\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch('https://api.openai.com/v1/models', {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nAdd the `DiscoveredModel` import to `openai.provider.ts`.\n\n- [ ] **Step 6: Run the phase tests**\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts src/modules/ai/__tests__/reconcile.test.ts`\n→ Expected: PASS. (Full `pnpm typecheck` is still red until other providers gain `listModels` in\nPhase 3 — expected.)\n\n- [ ] **Step 7: Commit**\n\n```bash\ngit add src/modules/ai/discovery src/modules/ai/__tests__/normalize.test.ts src/modules/ai/__tests__/reconcile.test.ts src/modules/ai/types.ts src/modules/ai/providers/openai.provider.ts\ngit commit -m \"feat(ai): discovery normalizer + obsolescence reconcile + openai listModels\"\n```\n\n---\n\n## Phase 3 — `listModels` for the remaining built-in providers\n\n**Files:**\n- Modify: `src/modules/ai/providers/{gemini,openrouter,mistral,moonshot,xai,anthropic,deepseek,qwen,zhipu}.provider.ts`\n\nFor each provider class, add a `listModels(apiKey)` method using the provider's models endpoint and\nthe shared normalizer where the response is OpenAI-shaped.\n\n- [ ] **Step 1: OpenAI-compatible `/v1/models` providers**\n\nFor `openrouter` (`https://openrouter.ai/api/v1/models`), `mistral`\n(`https://api.mistral.ai/v1/models`), `moonshot` (`https://api.moonshot.cn/v1/models`), `xai`\n(`https://api.x.ai/v1/models`), `deepseek` (`https://api.deepseek.com/models`), `anthropic`\n(`https://api.anthropic.com/v1/models`, header `x-api-key` + `anthropic-version: 2023-06-01`), add:\n\n```ts\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n// ...\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch('', { headers: { } });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nUse `Authorization: Bearer ${apiKey}` for all except anthropic (`'x-api-key': apiKey,\n'anthropic-version': '2023-06-01'`). Anthropic and OpenRouter return `{ data: [{ id }] }`, which\n`normalizeOpenAiModels` already handles.\n\n- [ ] **Step 2: Gemini (different response shape)**\n\n`gemini.provider.ts` — endpoint `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`\nreturns `{ models: [{ name: 'models/gemini-...' }] }`. Add a small local mapper:\n```ts\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);\n if (!res.ok) return { supported: true, models: [] };\n const raw = await res.json() as { models?: Array<{ name?: string }> };\n const ids = (raw.models ?? []).map(m => (m.name ?? '').replace(/^models\\//, '')).filter(Boolean);\n const models = ids.map(id => ({ id, modality: classifyModality(id), supports_thinking: detectThinking(id) }))\n .filter(m => m.modality === 'text');\n return { supported: true, models };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nImport `classifyModality, detectThinking` from `../discovery/normalize`.\n\n- [ ] **Step 3: Providers without a usable list endpoint**\n\nFor `qwen` and `zhipu`, return unsupported so the UI falls back to manual/bulk:\n```ts\nasync listModels(): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n return { supported: false, models: [] };\n}\n```\n(If during implementation a working models endpoint is confirmed for either, implement it like\nStep 1 instead. Default to `supported: false` when unverified.)\n\n- [ ] **Step 4: Verify every provider satisfies `IAIProvider`**\n\nRun: `pnpm typecheck 2>&1 | rg \"provider\" | head`\nExpected: no \"Property 'listModels' is missing\" errors for any provider class. (Other AI files may\nstill be red until Phase 7.)\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/modules/ai/providers\ngit commit -m \"feat(ai): listModels for all built-in providers (qwen/zhipu fall back to manual)\"\n```\n\n---\n\n## Phase 4 — custom OpenAI-compatible providers\n\n**Files:**\n- Create: `src/modules/ai/providers/openai-compatible.provider.ts`\n- Modify: `src/modules/ai/ai.service.ts` (factory: default branch → custom provider)\n- Create: `src/pages/api/admin/ai/custom-providers/index.ts` (GET list, POST create)\n- Create: `src/pages/api/admin/ai/custom-providers/[id].ts` (PUT update, DELETE)\n- Modify: `src/shared/validation/schemas/ai.ts` (custom provider schemas)\n- Create: `src/modules/ai/__tests__/openai-compatible.test.ts`\n\n- [ ] **Step 1: Failing test for the generic provider's normalization**\n\n`src/modules/ai/__tests__/openai-compatible.test.ts`:\n```ts\nimport { describe, it, expect, vi, afterEach } from 'vitest';\nimport { OpenAICompatibleProvider } from '../providers/openai-compatible.provider';\n\nafterEach(() => vi.restoreAllMocks());\n\ndescribe('OpenAICompatibleProvider.listModels', () => {\n it('lists text models from a custom base_url', async () => {\n vi.spyOn(globalThis, 'fetch').mockResolvedValue(\n new Response(JSON.stringify({ data: [{ id: 'custom-chat' }, { id: 'tts-x' }] }), { status: 200 }),\n );\n const p = new OpenAICompatibleProvider('my-endpoint', 'https://api.example.com/v1', 'k');\n const out = await p.listModels('k');\n expect(out.supported).toBe(true);\n expect(out.models.map(m => m.id)).toEqual(['custom-chat']);\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts` → Expected: FAIL.\n\n- [ ] **Step 2: Implement `openai-compatible.provider.ts`**\n\n```ts\nimport type { IAIProvider, GenerateContentRequest, GenerateContentResponse, DiscoveredModel } from '../types';\nimport { getSystemPrompt } from '../prompts';\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n\nexport class OpenAICompatibleProvider implements IAIProvider {\n readonly provider: string;\n constructor(provider: string, private baseUrl: string, private apiKey: string) {\n this.provider = provider;\n }\n private url(path: string) { return `${this.baseUrl.replace(/\\/$/, '')}${path}`; }\n\n async generateContent(request: GenerateContentRequest): Promise {\n const systemPrompt = getSystemPrompt(request.content_type, request.system_prompt);\n try {\n const res = await fetch(this.url('/chat/completions'), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` },\n body: JSON.stringify({\n model: request.model,\n messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: request.prompt }],\n temperature: request.temperature ?? 0.7,\n response_format: { type: 'json_object' },\n }),\n });\n const data = await res.json() as { choices?: Array<{ message?: { content?: string } }>; error?: { message?: string } };\n if (data.error) return { success: false, error: data.error.message || 'Provider error' };\n const text = data.choices?.[0]?.message?.content;\n if (!text) return { success: false, error: 'No content generated' };\n return { success: true, data: JSON.parse(text) };\n } catch (e) {\n return { success: false, error: `Generation failed: ${e instanceof Error ? e.message : 'Unknown error'}` };\n }\n }\n\n async validateApiKey(apiKey: string): Promise {\n try { return (await fetch(this.url('/models'), { headers: { Authorization: `Bearer ${apiKey}` } })).ok; }\n catch { return false; }\n }\n\n async listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch(this.url('/models'), { headers: { Authorization: `Bearer ${apiKey}` } });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch { return { supported: true, models: [] }; }\n }\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts` → Expected: PASS.\n\n- [ ] **Step 3: Open the factory to custom providers (`ai.service.ts`)**\n\nChange `createProvider` to accept an optional base_url and route unknown ids to the generic class:\n```ts\nexport function createProvider(provider: AIProvider, apiKey: string, baseUrl?: string): IAIProvider {\n switch (provider) {\n case 'gemini': return new GeminiProvider(apiKey);\n case 'openai': return new OpenAIProvider(apiKey);\n case 'anthropic': return new AnthropicProvider(apiKey);\n case 'deepseek': return new DeepSeekProvider(apiKey);\n case 'openrouter': return new OpenRouterProvider(apiKey);\n case 'qwen': return new QwenProvider(apiKey);\n case 'zhipu': return new ZhipuProvider(apiKey);\n case 'moonshot': return new MoonshotProvider(apiKey);\n case 'mistral': return new MistralProvider(apiKey);\n case 'xai': return new XAIProvider(apiKey);\n default:\n if (!baseUrl) throw new Error(`Unknown provider: ${provider}`);\n return new OpenAICompatibleProvider(provider, baseUrl, apiKey);\n }\n}\n```\nImport `OpenAICompatibleProvider`.\n\n- [ ] **Step 4: Custom-provider Zod schemas (`ai.ts`)**\n\nAdd to `src/shared/validation/schemas/ai.ts`:\n```ts\nexport const CreateCustomProviderSchema = z.object({\n id: z.string().min(1).regex(/^[a-z0-9-]+$/, 'id must be kebab-case'),\n label: z.string().min(1),\n base_url: z.string().url(),\n api_key: z.string().min(1),\n enabled: z.boolean().optional(),\n});\nexport const UpdateCustomProviderSchema = z.object({\n label: z.string().min(1).optional(),\n base_url: z.string().url().optional(),\n api_key: z.string().optional(),\n enabled: z.boolean().optional(),\n});\n```\n\n- [ ] **Step 5: Custom-provider CRUD routes**\n\n`src/pages/api/admin/ai/custom-providers/index.ts` — `GET` returns\n`stripApiKeys(settings).custom_providers`; `POST` validates `CreateCustomProviderSchema` then\n`saveAiSettings(db, { custom_providers: { [id]: { label, base_url, api_key, enabled: enabled ?? true, models: [] } } })`.\n`src/pages/api/admin/ai/custom-providers/[id].ts` — `PUT` validates `UpdateCustomProviderSchema`\nthen `saveAiSettings(db, { custom_providers: { [id]: patch } })` (deep merge keeps key/models);\n`DELETE` loads settings, removes the id from `custom_providers`, writes the full object via a\ndedicated `replaceAiSettings` (full overwrite) — add `replaceAiSettings(db, settings)` to\n`settings-store.ts` that validates + writes without merge. Both require `AuthRoles.ADMIN` via\n`extractAuthContext`/`hasRole` (follow `migrate-models.ts` auth pattern).\n\n- [ ] **Step 6: Verify + commit**\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts`\n→ Expected: PASS.\n```bash\ngit add src/modules/ai/providers/openai-compatible.provider.ts src/modules/ai/ai.service.ts src/modules/ai/settings-store.ts src/shared/validation/schemas/ai.ts src/pages/api/admin/ai/custom-providers src/modules/ai/__tests__/openai-compatible.test.ts\ngit commit -m \"feat(ai): OpenAI-compatible custom providers + CRUD\"\n```\n\n---\n\n## Phase 5 — discovery + settings + providers + generate endpoints (snake_case)\n\n**Files:**\n- Create: `src/pages/api/admin/ai/models/[provider]/discover.ts`\n- Modify: `src/pages/api/admin/ai/settings.ts`, `providers.ts`, `generate.ts`,\n `models/[provider].ts`, `models/[provider]/[modelId].ts`\n- Delete: `src/pages/api/admin/ai/migrate-models.ts`\n- Modify: `src/shared/validation/schemas/ai.ts` (snake_case settings/generate schemas)\n- Modify: `src/modules/ai/ai.service.ts` (`getModelsForProvider`, `generateContent`,\n `getConfiguredProviders`, resolution helper)\n\n- [ ] **Step 1: Rewrite `ai.ts` request schemas to snake_case**\n\nUpdate `GenerateSchema`, `UpdateSettingsSchema`, `AddModelSchema`, `UpdateModelSchema`,\n`ProviderModelParam`, `ValidateApiKeySchema` to snake_case keys:\n```ts\nexport const GenerateSchema = z.object({\n prompt: z.string().min(3).max(10000),\n content_type: z.enum(['recipe', 'article', 'roundup']),\n provider: z.string().optional(),\n model: z.string().min(1).optional(),\n temperature: z.number().min(0).max(2).optional(),\n reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),\n}).passthrough();\n\nexport const UpdateSettingsSchema = z.object({\n default_provider: z.string().optional(),\n default_model: z.string().optional(),\n temperature: z.number().min(0).max(1).optional(),\n system_prompt: z.string().optional(),\n providers: z.record(z.string(), z.object({\n api_key: z.string().optional(),\n enabled: z.boolean().optional(),\n models: z.array(z.any()).optional(),\n })).optional(),\n}).passthrough();\n\nexport const AddModelSchema = z.object({\n id: z.string().min(1),\n name: z.string().min(1),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n}).passthrough();\n\nexport const UpdateModelSchema = z.object({\n name: z.string().min(1).optional(),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n deprecated: z.boolean().optional(),\n enabled: z.boolean().optional(),\n}).passthrough();\n\nexport const ProviderModelParam = z.object({ provider: z.string().min(1), model_id: z.string().min(1) });\nexport const ValidateApiKeySchema = z.object({ provider: z.string().min(1), api_key: z.string().min(1) });\n```\n(`ProviderEnum` is replaced by `z.string()` since custom providers exist; validity is checked\nagainst settings at runtime.)\n\n- [ ] **Step 2: Discovery endpoint**\n\n`src/pages/api/admin/ai/models/[provider]/discover.ts` (`GET`, `AuthRoles.ADMIN`):\n```ts\n// resolve provider config (built-in providers[p] or custom_providers[p]) from getAiSettings\n// const cfg = settings.providers[provider] ?? settings.custom_providers[provider];\n// if (!cfg?.api_key) -> 400 \"No API key configured for \"\n// const impl = createProvider(provider, cfg.api_key, (cfg as CustomProviderConfig).base_url);\n// const { supported, models } = await impl.listModels(cfg.api_key);\n// const view = reconcileSelection(cfg.models ?? [], models);\n// return formatSuccessResponse({ supported, models: view });\n```\nUse `formatSuccessResponse`/`formatErrorResponse` and the `validateParams`-style param read used by\nsibling routes.\n\n- [ ] **Step 3: `settings.ts` GET (masked) / PUT (deep merge)**\n\nGET: `formatSuccessResponse(stripApiKeys(await getAiSettings(db)))`.\nPUT: `validateBody(request, UpdateSettingsSchema)` → `saveAiSettings(db, body)` → return\n`stripApiKeys(await getAiSettings(db))`.\nPOST (validate key): `createProvider(provider, api_key, base_url?).validateApiKey(api_key)`.\nRemove every camelCase field; require `AuthRoles.ADMIN`.\n\n- [ ] **Step 4: `providers.ts`, `generate.ts`, model CRUD to snake_case + selection**\n\n`providers.ts` GET → `{ configured_providers, available_models, provider_info, defaults }` where\n`available_models[p] = getModelsForProvider(db, p)` (stored selection, enabled only). Strip keys.\n`generate.ts` → read `GenerateSchema`, call `generateContent(db, { ...body })`, snake_case response.\n`models/[provider].ts` (POST add / GET list) and `models/[provider]/[modelId].ts` (PUT/DELETE) →\noperate on `providers[p].models` (or `custom_providers[p].models`) via `saveAiSettings`, snake_case\nfields, `source: 'manual'` for manually added models.\n\n- [ ] **Step 5: `ai.service.ts` resolution + generate**\n\n```ts\nexport async function getModelsForProvider(db: D1Database, provider: AIProvider): Promise {\n const settings = await getAiSettings(db);\n const cfg = settings.providers[provider as BuiltInProvider] ?? settings.custom_providers[provider];\n return (cfg?.models ?? []).filter(m => m.enabled && m.status !== 'unavailable');\n}\n\nexport async function generateContent(db: D1Database, request: GenerateContentRequest): Promise {\n const settings = await getAiSettings(db);\n const provider = request.provider || settings.default_provider;\n const cfg = settings.providers[provider as BuiltInProvider] ?? settings.custom_providers[provider];\n if (!cfg?.api_key) return { success: false, error: `No API key configured for provider: ${provider}` };\n if (!cfg.enabled) return { success: false, error: `Provider is disabled: ${provider}` };\n const impl = createProvider(provider, cfg.api_key, (cfg as CustomProviderConfig).base_url);\n const model = request.model || (provider === settings.default_provider ? settings.default_model : request.model);\n return impl.generateContent({\n ...request,\n model: model || request.model,\n temperature: request.temperature ?? settings.temperature,\n system_prompt: request.system_prompt || settings.system_prompt,\n });\n}\n```\nUpdate `getConfiguredProviders` to iterate `providers` + `custom_providers` reading `api_key`/`enabled`.\n\n- [ ] **Step 6: Delete the obsolete migration endpoint**\n\n```bash\ngit rm src/pages/api/admin/ai/migrate-models.ts\n```\nThen `rg -n \"migrate-models|AVAILABLE_MODELS\" src` and remove every remaining reference.\n\n- [ ] **Step 7: Verify + commit**\n\nRun: `pnpm test && pnpm check:boundaries`\nExpected: PASS / \"Boundary check passed.\" (`pnpm typecheck` may still be red until admin UI in\nPhase 7.)\n```bash\ngit add -A\ngit commit -m \"feat(ai): discovery + snake_case settings/providers/generate endpoints; drop migrate-models\"\n```\n\n---\n\n## Phase 6 — thinking / reasoning mapping at the provider boundary\n\n**Files:**\n- Modify: providers that support reasoning — `openai`, `anthropic`, `deepseek`, `openai-compatible`\n- Modify: `src/modules/ai/__tests__/...` (one mapping test)\n\n- [ ] **Step 1: Map `reasoning_effort` per provider at the fetch boundary**\n\nIn `openai.provider.ts` and `openai-compatible.provider.ts`, when `request.reasoning_effort` is set,\nadd `reasoning_effort: request.reasoning_effort` to the request body. In `anthropic.provider.ts`,\nmap to `thinking: { type: 'enabled', budget_tokens: }`. In\n`deepseek.provider.ts`, no body change is required (reasoner is model-selected) — leave a comment.\nProviders without support ignore the field (no change).\n\n- [ ] **Step 2: Test the mapping (openai body includes reasoning_effort)**\n\nAdd to an existing/new provider test that mocks `fetch` and asserts the request body passed to\n`fetch` contains `reasoning_effort: 'high'` when set. Run that test → Expected: PASS.\n\n- [ ] **Step 3: Commit**\n\n```bash\ngit add src/modules/ai/providers src/modules/ai/__tests__\ngit commit -m \"feat(ai): map reasoning_effort to provider-native thinking params\"\n```\n\n---\n\n## Phase 7 — admin UI: snake_case + discovery UX + has_api_key\n\n**Files:**\n- Modify: `src/admin/services/api.ts` (AI client methods)\n- Modify: `src/admin/components/ModelManager.tsx`, `BulkImportModels.tsx`\n- Modify: `src/admin/features/settings/pages/tabs/AISettings.tsx`,\n `src/admin/components/BlockEditor/components/AISettings.tsx`,\n `src/admin/features/settings/pages/Settings.tsx`\n\n- [ ] **Step 1: `api.ts` AI client methods → snake_case + discover + custom providers**\n\nAdd/repoint methods: `aiSettings.get/update`, `aiProviders.get`, `aiModels.discover(provider)`,\n`aiModels.add/update/remove`, `aiCustomProviders.list/create/update/remove`, `aiGenerate(payload)`.\nAll payloads/reads use snake_case (`content_type`, `reasoning_effort`, `default_provider`,\n`has_api_key`, `context_window`, `max_tokens`).\n\n- [ ] **Step 2: ModelManager — discovery checklist**\n\nRework `ManagedModel` to snake_case (`context_window`, `max_tokens`, `enabled`, `deprecated`,\n`status`). Add a \"Fetch latest models\" button calling `aiModels.discover(provider)`; render the\nreturned `models` as a checklist with `status` badges (`unavailable`/`deprecated`); ticking a model\nsaves it into the selection via `aiSettings.update`/`aiModels.add`. Keep manual add + bulk import\nvisible as fallback (and shown automatically when `discover` returns `supported: false`).\n\n- [ ] **Step 3: BulkImportModels + both AISettings tabs + Settings.tsx**\n\nRename all AI data-key reads/writes to snake_case (`context_window`, `max_tokens`, `api_key`→write\nonly, render `has_api_key`/`api_key_masked` instead of the raw key, `default_provider`,\n`default_model`, `system_prompt`, `reasoning_effort` selector shown when the chosen model has\n`supports_thinking`). Add a custom-provider add/edit form (id/label/base_url/api_key) backed by\n`aiCustomProviders`.\n\n- [ ] **Step 4: Verify full green**\n\nRun: `pnpm typecheck && pnpm test && pnpm check:boundaries`\nExpected: all green (this is the phase where typecheck returns fully clean).\n\n- [ ] **Step 5: Commit**\n\n```bash\ngit add src/admin\ngit commit -m \"feat(ai): snake_case admin AI UI + model discovery UX + write-only keys\"\n```\n\n---\n\n## Phase 8 — one-shot stored-blob data migration\n\n**Files:**\n- Create: `scripts/migrate-ai-settings.mts`\n- Create: `src/modules/ai/__tests__/migrate-ai-settings.test.ts`\n\n- [ ] **Step 1: Failing test for the pure transform**\n\n`src/modules/ai/__tests__/migrate-ai-settings.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { migrateAiSettingsBlob } from '../../../scripts/migrate-ai-settings.mts';\n\ndescribe('migrateAiSettingsBlob', () => {\n it('converts legacy camelCase blob to snake_case selection shape', () => {\n const legacy = {\n defaultProvider: 'openai', defaultModel: 'gpt-4o', temperature: 0.7, systemPrompt: 'sp',\n providers: { openai: { apiKey: 'sk-1', enabled: true,\n availableModels: [{ id: 'gpt-4o', name: 'GPT-4o', contextWindow: 131072, maxTokens: 65536, enabled: true }] } },\n };\n const out = migrateAiSettingsBlob(legacy);\n expect(out.default_provider).toBe('openai');\n expect(out.system_prompt).toBe('sp');\n expect(out.providers.openai.api_key).toBe('sk-1');\n expect(out.providers.openai.models[0]).toMatchObject({\n id: 'gpt-4o', context_window: 131072, max_tokens: 65536, modality: 'text',\n status: 'available', source: 'discovered', enabled: true,\n });\n expect(out.custom_providers).toEqual({});\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/migrate-ai-settings.test.ts` → Expected: FAIL.\n\n- [ ] **Step 2: Implement `scripts/migrate-ai-settings.mts`**\n\nExport a pure `migrateAiSettingsBlob(legacy)` (camelCase → snake_case, `availableModels` → `models`\nwith `modality:'text'`, `status:'available'`, `source:'discovered'`, `order` by index) plus a\n`main()` that opens the local D1 sqlite (reuse the `findDb` pattern from\n`scripts/migrate-credit-avatar-r2key.mts`), reads `site_settings.value WHERE key='ai_settings'`,\nprints a dry-run diff, and writes the transformed blob under `--apply`. Validate the result with\n`AiSettingsSchema` before writing.\n\nRun: `pnpm test src/modules/ai/__tests__/migrate-ai-settings.test.ts` → Expected: PASS.\n\n- [ ] **Step 3: Dry-run against local D1**\n\nRun: `node_modules/.bin/tsx scripts/migrate-ai-settings.mts`\nExpected: prints the would-be snake_case blob; no write. (If no `ai_settings` row exists locally,\nit prints \"no ai_settings row — nothing to migrate\".)\n\n- [ ] **Step 4: Commit**\n\n```bash\ngit add scripts/migrate-ai-settings.mts src/modules/ai/__tests__/migrate-ai-settings.test.ts\ngit commit -m \"chore(ai): one-shot migration of stored ai_settings to snake_case selection shape\"\n```\n\n---\n\n## Phase 9 — finalize & close AI portion of #3\n\n**Files:**\n- Modify: `docs/NAMING_CONTRACT.md` (only if it still implies AI is pending)\n- Modify: `.hermes/plans/2026-06-03_contract-audit-report.md` (local record)\n\n- [ ] **Step 1: Full regression gate**\n\nRun: `pnpm typecheck && pnpm test && pnpm check:boundaries`\nExpected: all green.\n\n- [ ] **Step 2: Contract audit — no AI camelCase regressions**\n\nRun: `rg -n \"\\b(defaultProvider|defaultModel|systemPrompt|apiKey|availableModels|contextWindow|maxTokens|modelId)\\b\" src/modules/ai src/pages/api/admin/ai src/admin --glob '!**/*.test.*'`\nExpected: matches only inside provider classes at the external fetch boundary (allowed exception)\nand local variables — no app-owned data-shape keys. Then\n`node scripts/local-contract-audit.mjs --summary` → no new camelCase violation.\n\n- [ ] **Step 3: Commit (if docs changed)**\n\n```bash\ngit add docs/NAMING_CONTRACT.md\ngit commit -m \"docs(naming): AI subsystem migrated to snake_case (audit #3)\"\n```\n\n---\n\n## Self-review notes\n\n- **Spec coverage:** stored shape (P0/P1/P5), discovery `listModels` (P2/P3), discover endpoint\n (P5), custom providers (P4), safe persistence + Zod (P1), key masking (P1/P5), obsolescence\n reconcile (P2 + P5 resolution filter), thinking (P6), modality filter (P2 normalizer), snake_case\n E2E (P0/P5/P7), data migration (P8), cleanup of AVAILABLE_MODELS + migrate-models (P5). ✓\n- **No placeholders:** pure-logic units ship full code + tests; mechanical endpoint/UI tasks give\n exact files, exact snake_case key lists, and a shared normalizer (DRY) rather than repeated\n provider code. ✓\n- **Type consistency:** `AiSettings`, `ModelSelection`, `DiscoveredModel`, `ListModelsResult`,\n `reconcileSelection`, `mergeAiSettings`, `getAiSettings`/`saveAiSettings`/`replaceAiSettings`,\n `stripApiKeys`/`maskApiKey`, `normalizeOpenAiModels`/`classifyModality`/`detectThinking` are used\n with consistent names across phases. ✓\n- **Typecheck phasing:** Phases 1, 2, 3, 8 are green in isolation; Phases 0, 4–7 may leave global\n typecheck red until Phase 7 (documented in the Phase 0 note); every phase's own unit tests pass.\n"}, "8b9fe9370339660d17c57de604586da34a3651cc": {"t": 1786068475.3151045, "n": 47698, "h": "8e72655065098b35", "c": "# AI Model Management Redesign Implementation Plan\n\n> **Status: COMPLETED** (all tasks done; module shipped with tests).\n\n> **STATUS — 2026-06-04: ✅ COMPLETED, REVIEWED & FIXED.** All phases implemented (snake_case types/store/discovery/providers/custom providers/endpoints/reasoning/admin UI + data migration). Code review fixes applied (`a71be82`: anthropic max_tokens>budget, provider-id collision guard, un-deprecate, editor reasoning_effort). Stored `ai_settings` blob migrated locally (`migrate-ai-settings --apply`). Verified: typecheck 0 · tests green · boundaries ✅. Ops: run `migrate-ai-settings --apply` on prod D1 at deploy.\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Replace the hardcoded AI model catalog with live per-provider discovery, store only the admin's curated snake_case selection with safe validated persistence and write-only keys, add OpenAI-compatible custom providers, model-obsolescence reconciliation, and a thinking/reasoning control — closing the AI portion of Contract Audit #3.\n\n**Architecture:** Pure-logic units (snake_case types, Zod stored-blob schema, deep-merge persistence, key masking, model normalization/modality classification, obsolescence reconciliation) are built test-first. Provider `listModels` implementations share one OpenAI-style normalizer. API routes, the admin UI, and the client are migrated to snake_case and wired to discovery. A one-shot script migrates the existing stored blob.\n\n**Tech Stack:** Cloudflare D1 (`site_settings`), Zod, Astro API routes, React admin SPA, Vitest, `node:sqlite` for the migration script.\n\n**Spec:** `docs/superpowers/specs/2026-06-04-ai-model-management-redesign-design.md`\n\n---\n\n## Conventions (read once)\n\n- **snake_case** for all app-owned data keys (stored blob, our API envelopes, admin props, types\n that model them). Local variables/functions/React state may stay camelCase (`NAMING_CONTRACT`\n L21). External provider HTTP request/response field names stay as the provider names **only\n inside each provider class** at the fetch boundary (Allowed Exception).\n- **TDD** for pure units (types are compile-time; logic units get a failing test first). Mechanical\n snake_case renames and UI wiring are verified by `pnpm test && pnpm check:boundaries &&\n pnpm typecheck`.\n- **Run between every task:** `pnpm test && pnpm check:boundaries && pnpm typecheck`. All must be\n green before the task's commit.\n- Test files live next to the code under `__tests__/` per existing convention\n (e.g. `src/modules/ai/__tests__/...`).\n\n## File structure (created / modified)\n\n- `src/modules/ai/types.ts` — replace AISettings/AIModel/AVAILABLE_MODELS with snake_case\n `AiSettings`, `ModelSelection`, `DiscoveredModel`, capability enums; keep `ALL_PROVIDERS`,\n `PROVIDER_INFO`, `DEFAULT_AI_SETTINGS` (snake).\n- `src/modules/ai/settings-schema.ts` (new) — Zod schema for the stored blob.\n- `src/modules/ai/settings-store.ts` (new) — `getAiSettings` / `saveAiSettings` (deep merge +\n validate) extracted from `ai.service.ts`.\n- `src/modules/ai/api-key.ts` (new) — `maskApiKey`, `stripApiKeys`.\n- `src/modules/ai/discovery/normalize.ts` (new) — `normalizeOpenAiModels`, `classifyModality`,\n `detectThinking`.\n- `src/modules/ai/discovery/reconcile.ts` (new) — `reconcileSelection`.\n- `src/modules/ai/providers/openai-compatible.provider.ts` (new) — generic custom provider.\n- `src/modules/ai/providers/*.provider.ts` — add `listModels`; snake_case app-owned fields.\n- `src/modules/ai/ai.service.ts` — discovery resolution, generate with `reasoning_effort`,\n open-ended factory.\n- `src/shared/validation/schemas/ai.ts` — snake_case Zod schemas + `reasoning_effort`.\n- `src/pages/api/admin/ai/models/[provider]/discover.ts` (new) — discovery endpoint.\n- `src/pages/api/admin/ai/settings.ts`, `providers.ts`, `generate.ts`,\n `models/[provider]/[modelId].ts`, `models/[provider].ts` — snake_case + masking + custom CRUD.\n- `src/pages/api/admin/ai/migrate-models.ts` — **delete**.\n- `src/pages/api/admin/ai/custom-providers/*` (new) — custom provider CRUD.\n- `src/admin/components/ModelManager.tsx`, `BulkImportModels.tsx`,\n `src/admin/features/settings/pages/tabs/AISettings.tsx`,\n `src/admin/components/BlockEditor/components/AISettings.tsx`,\n `src/admin/features/settings/pages/Settings.tsx`, `src/admin/services/api.ts` — snake_case +\n discovery UX + `has_api_key`.\n- `scripts/migrate-ai-settings.mts` (new) — one-shot stored-blob migration.\n\n---\n\n## Phase 0 — snake_case types & defaults\n\n**Files:**\n- Modify: `src/modules/ai/types.ts`\n\n- [x] **Step 1: Replace the data-shape interfaces with snake_case**\n\nReplace `AIModel`, `AISettings`, `DEFAULT_AI_SETTINGS`, and remove `AVAILABLE_MODELS`. Keep\n`AIProvider`, `ALL_PROVIDERS`, `PROVIDER_INFO`, `IAIProvider`, `GenerateContentRequest`,\n`GeneratedRecipe`. New definitions:\n\n```ts\n/** Built-in provider id, or a custom OpenAI-compatible provider id (slug). */\nexport type BuiltInProvider =\n | 'gemini' | 'openai' | 'anthropic' | 'deepseek' | 'openrouter'\n | 'qwen' | 'zhipu' | 'moonshot' | 'mistral' | 'xai';\nexport type AIProvider = BuiltInProvider | (string & {});\n\nexport type ModelModality = 'text' | 'image' | 'audio' | 'embedding' | 'other';\nexport type ModelStatus = 'available' | 'unavailable' | 'deprecated';\n\n/** A model the provider currently offers (discovery output). */\nexport interface DiscoveredModel {\n id: string;\n name?: string;\n context_window?: number;\n max_tokens?: number;\n modality: ModelModality;\n supports_thinking?: boolean;\n deprecated?: boolean;\n}\n\n/** A model the admin has curated into the stored selection. */\nexport interface ModelSelection {\n id: string;\n name?: string;\n context_window?: number;\n max_tokens?: number;\n modality: ModelModality;\n supports_thinking?: boolean;\n enabled: boolean;\n order: number;\n deprecated?: boolean;\n status: ModelStatus;\n source: 'discovered' | 'manual';\n}\n\nexport interface ProviderConfig {\n enabled: boolean;\n api_key: string;\n models: ModelSelection[];\n}\n\nexport interface CustomProviderConfig extends ProviderConfig {\n label: string;\n base_url: string;\n}\n\nexport interface AiSettings {\n default_provider: AIProvider;\n default_model: string;\n temperature: number;\n system_prompt: string;\n providers: Partial>;\n custom_providers: Record;\n}\n\nexport const DEFAULT_AI_SETTINGS: AiSettings = {\n default_provider: 'gemini',\n default_model: 'gemini-3-flash-preview',\n temperature: 0.7,\n system_prompt: `You are an expert culinary content creator. Generate structured recipe and article content in JSON format.\nAlways respond with valid JSON that matches the expected schema.\nBe creative, detailed, and provide practical cooking tips.`,\n providers: {},\n custom_providers: {},\n};\n```\n\nUpdate `GenerateContentRequest` to snake_case app-owned fields and add the reasoning control:\n\n```ts\nexport interface GenerateContentRequest {\n prompt: string;\n content_type: 'recipe' | 'article' | 'roundup';\n provider: AIProvider;\n model: string;\n temperature?: number;\n system_prompt?: string;\n reasoning_effort?: 'low' | 'medium' | 'high';\n}\n```\n\n- [x] **Step 2: Typecheck (expect downstream breakages — they are fixed in later phases)**\n\nRun: `pnpm typecheck 2>&1 | head -40`\nExpected: errors only in AI files that consume the old names (ai.service, providers, endpoints,\nadmin). These are addressed in Phases 1–7. Do NOT fix unrelated files here.\n\n- [x] **Step 3: Commit**\n\n```bash\ngit add src/modules/ai/types.ts\ngit commit -m \"refactor(ai): snake_case AiSettings + add discovery/selection model types\"\n```\n\n> Note for the executor: Phases 0–7 form one compile-coherent unit. Commit per phase, but\n> `pnpm typecheck` only returns fully green at the end of Phase 7. Phases that CAN be green in\n> isolation (1, 2, 3, 8) state so; Phases 0, 4–7 may leave typecheck red until Phase 7. Each\n> phase's own unit tests must pass.\n\n---\n\n## Phase 1 — safe persistence, validation, key masking\n\n**Files:**\n- Create: `src/modules/ai/settings-schema.ts`\n- Create: `src/modules/ai/api-key.ts`\n- Create: `src/modules/ai/settings-store.ts`\n- Create: `src/modules/ai/__tests__/settings-store.test.ts`\n- Create: `src/modules/ai/__tests__/api-key.test.ts`\n\n- [x] **Step 1: Write failing tests for api-key masking**\n\n`src/modules/ai/__tests__/api-key.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { maskApiKey, stripApiKeys } from '../api-key';\n\ndescribe('maskApiKey', () => {\n it('masks all but the last 4 chars', () => {\n expect(maskApiKey('sk-abcdef1234')).toBe('••••1234');\n });\n it('returns empty string for empty/undefined', () => {\n expect(maskApiKey('')).toBe('');\n expect(maskApiKey(undefined)).toBe('');\n });\n});\n\ndescribe('stripApiKeys', () => {\n it('replaces api_key with has_api_key + masked across providers and custom_providers', () => {\n const out = stripApiKeys({\n default_provider: 'openai', default_model: 'm', temperature: 0.7, system_prompt: 's',\n providers: { openai: { enabled: true, api_key: 'sk-xxxx9999', models: [] } },\n custom_providers: { c1: { label: 'C', base_url: 'u', enabled: true, api_key: 'k-1111', models: [] } },\n });\n expect(out.providers.openai).toMatchObject({ has_api_key: true, api_key_masked: '••••9999' });\n expect('api_key' in out.providers.openai).toBe(false);\n expect(out.custom_providers.c1).toMatchObject({ has_api_key: true, api_key_masked: '••••1111' });\n expect('api_key' in out.custom_providers.c1).toBe(false);\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/api-key.test.ts` → Expected: FAIL (module missing).\n\n- [x] **Step 2: Implement `api-key.ts`**\n\n```ts\nimport type { AiSettings } from './types';\n\nexport function maskApiKey(key: string | undefined): string {\n if (!key) return '';\n return `••••${key.slice(-4)}`;\n}\n\ntype StrippedProvider = { enabled: boolean; models: unknown[]; has_api_key: boolean; api_key_masked: string };\n\nfunction strip(cfg: T): Omit & { has_api_key: boolean; api_key_masked: string } {\n const { api_key, ...rest } = cfg;\n return { ...rest, has_api_key: Boolean(api_key), api_key_masked: maskApiKey(api_key) };\n}\n\nexport function stripApiKeys(settings: AiSettings) {\n return {\n ...settings,\n providers: Object.fromEntries(\n Object.entries(settings.providers).map(([k, v]) => [k, strip(v!)]),\n ),\n custom_providers: Object.fromEntries(\n Object.entries(settings.custom_providers).map(([k, v]) => [k, strip(v)]),\n ),\n };\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/api-key.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Implement the Zod stored-blob schema (`settings-schema.ts`)**\n\n```ts\nimport { z } from 'zod';\n\nconst ModelSelectionSchema = z.object({\n id: z.string().min(1),\n name: z.string().optional(),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n modality: z.enum(['text', 'image', 'audio', 'embedding', 'other']).default('text'),\n supports_thinking: z.boolean().optional(),\n enabled: z.boolean().default(true),\n order: z.number().int().nonnegative().default(0),\n deprecated: z.boolean().optional(),\n status: z.enum(['available', 'unavailable', 'deprecated']).default('available'),\n source: z.enum(['discovered', 'manual']).default('discovered'),\n});\n\nconst ProviderConfigSchema = z.object({\n enabled: z.boolean().default(false),\n api_key: z.string().default(''),\n models: z.array(ModelSelectionSchema).default([]),\n});\n\nconst CustomProviderConfigSchema = ProviderConfigSchema.extend({\n label: z.string().min(1),\n base_url: z.string().url(),\n});\n\nexport const AiSettingsSchema = z.object({\n default_provider: z.string().min(1),\n default_model: z.string().default(''),\n temperature: z.number().min(0).max(2).default(0.7),\n system_prompt: z.string().default(''),\n providers: z.record(z.string(), ProviderConfigSchema).default({}),\n custom_providers: z.record(z.string(), CustomProviderConfigSchema).default({}),\n});\n```\n\n- [x] **Step 4: Write failing tests for the store (deep merge + validate + fallback)**\n\n`src/modules/ai/__tests__/settings-store.test.ts`:\n```ts\nimport { describe, it, expect, vi } from 'vitest';\nimport { mergeAiSettings } from '../settings-store';\nimport { DEFAULT_AI_SETTINGS } from '../types';\n\ndescribe('mergeAiSettings', () => {\n it('deep-merges one provider without dropping others', () => {\n const current = {\n ...DEFAULT_AI_SETTINGS,\n providers: {\n openai: { enabled: true, api_key: 'sk-1', models: [] },\n gemini: { enabled: true, api_key: 'g-1', models: [] },\n },\n };\n const merged = mergeAiSettings(current, { providers: { openai: { enabled: false } } });\n expect(merged.providers.gemini).toEqual({ enabled: true, api_key: 'g-1', models: [] });\n expect(merged.providers.openai).toMatchObject({ enabled: false, api_key: 'sk-1' });\n });\n\n it('keeps existing api_key when the patch omits it', () => {\n const current = { ...DEFAULT_AI_SETTINGS, providers: { openai: { enabled: true, api_key: 'sk-keep', models: [] } } };\n const merged = mergeAiSettings(current, { providers: { openai: { models: [] } } });\n expect(merged.providers.openai.api_key).toBe('sk-keep');\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/settings-store.test.ts` → Expected: FAIL.\n\n- [x] **Step 5: Implement `settings-store.ts`**\n\n```ts\nimport type { D1Database } from '@cloudflare/workers-types';\nimport type { AiSettings } from './types';\nimport { DEFAULT_AI_SETTINGS } from './types';\nimport { AiSettingsSchema } from './settings-schema';\n\nconst AI_SETTINGS_KEY = 'ai_settings';\n\nfunction mergeProviderMap>(current: T, patch: Partial | undefined): T {\n if (!patch) return current;\n const out: Record = { ...current };\n for (const [id, cfg] of Object.entries(patch)) {\n out[id] = { ...(current[id] ?? {}), ...(cfg as object) };\n }\n return out as T;\n}\n\nexport function mergeAiSettings(current: AiSettings, patch: Partial): AiSettings {\n return {\n ...current,\n ...patch,\n providers: mergeProviderMap(current.providers, patch.providers),\n custom_providers: mergeProviderMap(current.custom_providers, patch.custom_providers),\n };\n}\n\nexport async function getAiSettings(db: D1Database): Promise {\n try {\n const row = await db.prepare('SELECT value FROM site_settings WHERE key = ?')\n .bind(AI_SETTINGS_KEY).first<{ value: string }>();\n if (row?.value) {\n const parsed = AiSettingsSchema.safeParse(JSON.parse(row.value));\n if (parsed.success) return parsed.data as AiSettings;\n console.error('Invalid ai_settings blob, using defaults:', parsed.error.issues);\n }\n } catch (e) {\n console.error('Failed to load AI settings:', e);\n }\n return DEFAULT_AI_SETTINGS;\n}\n\nexport async function saveAiSettings(db: D1Database, patch: Partial): Promise {\n try {\n const merged = mergeAiSettings(await getAiSettings(db), patch);\n const validated = AiSettingsSchema.parse(merged);\n await db.prepare(`\n INSERT INTO site_settings (key, value, category, type, updated_at)\n VALUES (?, ?, 'ai', 'json', CURRENT_TIMESTAMP)\n ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP\n `).bind(AI_SETTINGS_KEY, JSON.stringify(validated)).run();\n return true;\n } catch (e) {\n console.error('Failed to save AI settings:', e);\n return false;\n }\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/settings-store.test.ts src/modules/ai/__tests__/api-key.test.ts`\n→ Expected: PASS.\n\n- [x] **Step 6: Commit**\n\n```bash\ngit add src/modules/ai/settings-schema.ts src/modules/ai/api-key.ts src/modules/ai/settings-store.ts src/modules/ai/__tests__/settings-store.test.ts src/modules/ai/__tests__/api-key.test.ts\ngit commit -m \"feat(ai): validated deep-merge settings store + write-only key masking\"\n```\n\n---\n\n## Phase 2 — discovery core (normalize, classify, reconcile) + openai listModels\n\n**Files:**\n- Create: `src/modules/ai/discovery/normalize.ts`\n- Create: `src/modules/ai/discovery/reconcile.ts`\n- Create: `src/modules/ai/__tests__/normalize.test.ts`\n- Create: `src/modules/ai/__tests__/reconcile.test.ts`\n- Modify: `src/modules/ai/types.ts` (add `listModels` to `IAIProvider`)\n- Modify: `src/modules/ai/providers/openai.provider.ts`\n\n- [x] **Step 1: Write failing tests for `normalize.ts`**\n\n`src/modules/ai/__tests__/normalize.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { classifyModality, detectThinking, normalizeOpenAiModels } from '../discovery/normalize';\n\ndescribe('classifyModality', () => {\n it('classifies known non-text ids', () => {\n expect(classifyModality('dall-e-3')).toBe('image');\n expect(classifyModality('gpt-image-1')).toBe('image');\n expect(classifyModality('tts-1')).toBe('audio');\n expect(classifyModality('text-embedding-3-large')).toBe('embedding');\n expect(classifyModality('gpt-5.1-chat-latest')).toBe('text');\n });\n});\n\ndescribe('detectThinking', () => {\n it('flags reasoning model ids', () => {\n expect(detectThinking('o3-mini')).toBe(true);\n expect(detectThinking('deepseek-reasoner')).toBe(true);\n expect(detectThinking('gpt-4o')).toBe(false);\n });\n});\n\ndescribe('normalizeOpenAiModels', () => {\n it('maps an OpenAI /v1/models list to text-only DiscoveredModel[]', () => {\n const raw = { data: [{ id: 'gpt-5.1-chat-latest' }, { id: 'dall-e-3' }, { id: 'tts-1' }] };\n const models = normalizeOpenAiModels(raw);\n expect(models.map(m => m.id)).toEqual(['gpt-5.1-chat-latest']);\n expect(models[0]).toMatchObject({ id: 'gpt-5.1-chat-latest', modality: 'text', supports_thinking: false });\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts` → Expected: FAIL.\n\n- [x] **Step 2: Implement `discovery/normalize.ts`**\n\n```ts\nimport type { DiscoveredModel, ModelModality } from '../types';\n\nconst IMAGE_RE = /(dall-e|gpt-image|imagen|stable-diffusion|flux)/i;\nconst AUDIO_RE = /(tts|whisper|audio|speech|voice)/i;\nconst EMBED_RE = /(embedding|embed|rerank)/i;\nconst THINK_RE = /(^o\\d|reason|thinking|-r1\\b|deepseek-reasoner)/i;\n\nexport function classifyModality(id: string): ModelModality {\n if (IMAGE_RE.test(id)) return 'image';\n if (AUDIO_RE.test(id)) return 'audio';\n if (EMBED_RE.test(id)) return 'embedding';\n return 'text';\n}\n\nexport function detectThinking(id: string): boolean {\n return THINK_RE.test(id);\n}\n\n/** Normalize an OpenAI-compatible `{ data: [{ id }] }` models response to text-only models. */\nexport function normalizeOpenAiModels(raw: unknown): DiscoveredModel[] {\n const data = (raw as { data?: Array<{ id?: string }> })?.data ?? [];\n return data\n .map((m) => m?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n .map((id) => ({ id, modality: classifyModality(id), supports_thinking: detectThinking(id) }))\n .filter((m) => m.modality === 'text');\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Write failing tests for `reconcile.ts`**\n\n`src/modules/ai/__tests__/reconcile.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { reconcileSelection } from '../discovery/reconcile';\n\ndescribe('reconcileSelection', () => {\n const discovered = [{ id: 'a', modality: 'text' as const }, { id: 'b', modality: 'text' as const, deprecated: true }];\n it('marks selected, deprecated, and unavailable', () => {\n const stored = [\n { id: 'a', enabled: true, order: 0, modality: 'text' as const, status: 'available' as const, source: 'discovered' as const },\n { id: 'gone', enabled: true, order: 1, modality: 'text' as const, status: 'available' as const, source: 'discovered' as const },\n ];\n const view = reconcileSelection(stored, discovered);\n expect(view.find(m => m.id === 'a')).toMatchObject({ selected: true, status: 'available' });\n expect(view.find(m => m.id === 'b')).toMatchObject({ selected: false, status: 'deprecated' });\n expect(view.find(m => m.id === 'gone')).toMatchObject({ selected: true, status: 'unavailable' });\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/reconcile.test.ts` → Expected: FAIL.\n\n- [x] **Step 4: Implement `discovery/reconcile.ts`**\n\n```ts\nimport type { DiscoveredModel, ModelSelection, ModelStatus } from '../types';\n\nexport interface ReconciledModel extends DiscoveredModel {\n selected: boolean;\n status: ModelStatus;\n}\n\nexport function reconcileSelection(\n stored: ModelSelection[],\n discovered: DiscoveredModel[],\n): ReconciledModel[] {\n const storedById = new Map(stored.map((m) => [m.id, m]));\n const discoveredById = new Map(discovered.map((m) => [m.id, m]));\n\n const out: ReconciledModel[] = discovered.map((d) => ({\n ...d,\n selected: storedById.has(d.id),\n status: d.deprecated ? 'deprecated' : 'available',\n }));\n\n for (const s of stored) {\n if (!discoveredById.has(s.id)) {\n out.push({ id: s.id, name: s.name, modality: s.modality, supports_thinking: s.supports_thinking, selected: true, status: 'unavailable' });\n }\n }\n return out;\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/reconcile.test.ts` → Expected: PASS.\n\n- [x] **Step 5: Add `listModels` to `IAIProvider` and implement it for OpenAI**\n\nIn `types.ts`, extend the interface:\n```ts\nexport interface ListModelsResult { supported: boolean; models: DiscoveredModel[]; }\nexport interface IAIProvider {\n readonly provider: AIProvider;\n generateContent(request: GenerateContentRequest): Promise;\n validateApiKey(apiKey: string): Promise;\n listModels(apiKey: string): Promise;\n}\n```\n\nIn `openai.provider.ts`, add the method (reuse the existing `/v1/models` fetch):\n```ts\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n// ...\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch('https://api.openai.com/v1/models', {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nAdd the `DiscoveredModel` import to `openai.provider.ts`.\n\n- [x] **Step 6: Run the phase tests**\n\nRun: `pnpm test src/modules/ai/__tests__/normalize.test.ts src/modules/ai/__tests__/reconcile.test.ts`\n→ Expected: PASS. (Full `pnpm typecheck` is still red until other providers gain `listModels` in\nPhase 3 — expected.)\n\n- [x] **Step 7: Commit**\n\n```bash\ngit add src/modules/ai/discovery src/modules/ai/__tests__/normalize.test.ts src/modules/ai/__tests__/reconcile.test.ts src/modules/ai/types.ts src/modules/ai/providers/openai.provider.ts\ngit commit -m \"feat(ai): discovery normalizer + obsolescence reconcile + openai listModels\"\n```\n\n---\n\n## Phase 3 — `listModels` for the remaining built-in providers\n\n**Files:**\n- Modify: `src/modules/ai/providers/{gemini,openrouter,mistral,moonshot,xai,anthropic,deepseek,qwen,zhipu}.provider.ts`\n\nFor each provider class, add a `listModels(apiKey)` method using the provider's models endpoint and\nthe shared normalizer where the response is OpenAI-shaped.\n\n- [x] **Step 1: OpenAI-compatible `/v1/models` providers**\n\nFor `openrouter` (`https://openrouter.ai/api/v1/models`), `mistral`\n(`https://api.mistral.ai/v1/models`), `moonshot` (`https://api.moonshot.cn/v1/models`), `xai`\n(`https://api.x.ai/v1/models`), `deepseek` (`https://api.deepseek.com/models`), `anthropic`\n(`https://api.anthropic.com/v1/models`, header `x-api-key` + `anthropic-version: 2023-06-01`), add:\n\n```ts\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n// ...\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch('', { headers: { } });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nUse `Authorization: Bearer ${apiKey}` for all except anthropic (`'x-api-key': apiKey,\n'anthropic-version': '2023-06-01'`). Anthropic and OpenRouter return `{ data: [{ id }] }`, which\n`normalizeOpenAiModels` already handles.\n\n- [x] **Step 2: Gemini (different response shape)**\n\n`gemini.provider.ts` — endpoint `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`\nreturns `{ models: [{ name: 'models/gemini-...' }] }`. Add a small local mapper:\n```ts\nasync listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);\n if (!res.ok) return { supported: true, models: [] };\n const raw = await res.json() as { models?: Array<{ name?: string }> };\n const ids = (raw.models ?? []).map(m => (m.name ?? '').replace(/^models\\//, '')).filter(Boolean);\n const models = ids.map(id => ({ id, modality: classifyModality(id), supports_thinking: detectThinking(id) }))\n .filter(m => m.modality === 'text');\n return { supported: true, models };\n } catch {\n return { supported: true, models: [] };\n }\n}\n```\nImport `classifyModality, detectThinking` from `../discovery/normalize`.\n\n- [x] **Step 3: Providers without a usable list endpoint**\n\nFor `qwen` and `zhipu`, return unsupported so the UI falls back to manual/bulk:\n```ts\nasync listModels(): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n return { supported: false, models: [] };\n}\n```\n(If during implementation a working models endpoint is confirmed for either, implement it like\nStep 1 instead. Default to `supported: false` when unverified.)\n\n- [x] **Step 4: Verify every provider satisfies `IAIProvider`**\n\nRun: `pnpm typecheck 2>&1 | rg \"provider\" | head`\nExpected: no \"Property 'listModels' is missing\" errors for any provider class. (Other AI files may\nstill be red until Phase 7.)\n\n- [x] **Step 5: Commit**\n\n```bash\ngit add src/modules/ai/providers\ngit commit -m \"feat(ai): listModels for all built-in providers (qwen/zhipu fall back to manual)\"\n```\n\n---\n\n## Phase 4 — custom OpenAI-compatible providers\n\n**Files:**\n- Create: `src/modules/ai/providers/openai-compatible.provider.ts`\n- Modify: `src/modules/ai/ai.service.ts` (factory: default branch → custom provider)\n- Create: `src/pages/api/admin/ai/custom-providers/index.ts` (GET list, POST create)\n- Create: `src/pages/api/admin/ai/custom-providers/[id].ts` (PUT update, DELETE)\n- Modify: `src/shared/validation/schemas/ai.ts` (custom provider schemas)\n- Create: `src/modules/ai/__tests__/openai-compatible.test.ts`\n\n- [x] **Step 1: Failing test for the generic provider's normalization**\n\n`src/modules/ai/__tests__/openai-compatible.test.ts`:\n```ts\nimport { describe, it, expect, vi, afterEach } from 'vitest';\nimport { OpenAICompatibleProvider } from '../providers/openai-compatible.provider';\n\nafterEach(() => vi.restoreAllMocks());\n\ndescribe('OpenAICompatibleProvider.listModels', () => {\n it('lists text models from a custom base_url', async () => {\n vi.spyOn(globalThis, 'fetch').mockResolvedValue(\n new Response(JSON.stringify({ data: [{ id: 'custom-chat' }, { id: 'tts-x' }] }), { status: 200 }),\n );\n const p = new OpenAICompatibleProvider('my-endpoint', 'https://api.example.com/v1', 'k');\n const out = await p.listModels('k');\n expect(out.supported).toBe(true);\n expect(out.models.map(m => m.id)).toEqual(['custom-chat']);\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts` → Expected: FAIL.\n\n- [x] **Step 2: Implement `openai-compatible.provider.ts`**\n\n```ts\nimport type { IAIProvider, GenerateContentRequest, GenerateContentResponse, DiscoveredModel } from '../types';\nimport { getSystemPrompt } from '../prompts';\nimport { normalizeOpenAiModels } from '../discovery/normalize';\n\nexport class OpenAICompatibleProvider implements IAIProvider {\n readonly provider: string;\n constructor(provider: string, private baseUrl: string, private apiKey: string) {\n this.provider = provider;\n }\n private url(path: string) { return `${this.baseUrl.replace(/\\/$/, '')}${path}`; }\n\n async generateContent(request: GenerateContentRequest): Promise {\n const systemPrompt = getSystemPrompt(request.content_type, request.system_prompt);\n try {\n const res = await fetch(this.url('/chat/completions'), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` },\n body: JSON.stringify({\n model: request.model,\n messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: request.prompt }],\n temperature: request.temperature ?? 0.7,\n response_format: { type: 'json_object' },\n }),\n });\n const data = await res.json() as { choices?: Array<{ message?: { content?: string } }>; error?: { message?: string } };\n if (data.error) return { success: false, error: data.error.message || 'Provider error' };\n const text = data.choices?.[0]?.message?.content;\n if (!text) return { success: false, error: 'No content generated' };\n return { success: true, data: JSON.parse(text) };\n } catch (e) {\n return { success: false, error: `Generation failed: ${e instanceof Error ? e.message : 'Unknown error'}` };\n }\n }\n\n async validateApiKey(apiKey: string): Promise {\n try { return (await fetch(this.url('/models'), { headers: { Authorization: `Bearer ${apiKey}` } })).ok; }\n catch { return false; }\n }\n\n async listModels(apiKey: string): Promise<{ supported: boolean; models: DiscoveredModel[] }> {\n try {\n const res = await fetch(this.url('/models'), { headers: { Authorization: `Bearer ${apiKey}` } });\n if (!res.ok) return { supported: true, models: [] };\n return { supported: true, models: normalizeOpenAiModels(await res.json()) };\n } catch { return { supported: true, models: [] }; }\n }\n}\n```\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Open the factory to custom providers (`ai.service.ts`)**\n\nChange `createProvider` to accept an optional base_url and route unknown ids to the generic class:\n```ts\nexport function createProvider(provider: AIProvider, apiKey: string, baseUrl?: string): IAIProvider {\n switch (provider) {\n case 'gemini': return new GeminiProvider(apiKey);\n case 'openai': return new OpenAIProvider(apiKey);\n case 'anthropic': return new AnthropicProvider(apiKey);\n case 'deepseek': return new DeepSeekProvider(apiKey);\n case 'openrouter': return new OpenRouterProvider(apiKey);\n case 'qwen': return new QwenProvider(apiKey);\n case 'zhipu': return new ZhipuProvider(apiKey);\n case 'moonshot': return new MoonshotProvider(apiKey);\n case 'mistral': return new MistralProvider(apiKey);\n case 'xai': return new XAIProvider(apiKey);\n default:\n if (!baseUrl) throw new Error(`Unknown provider: ${provider}`);\n return new OpenAICompatibleProvider(provider, baseUrl, apiKey);\n }\n}\n```\nImport `OpenAICompatibleProvider`.\n\n- [x] **Step 4: Custom-provider Zod schemas (`ai.ts`)**\n\nAdd to `src/shared/validation/schemas/ai.ts`:\n```ts\nexport const CreateCustomProviderSchema = z.object({\n id: z.string().min(1).regex(/^[a-z0-9-]+$/, 'id must be kebab-case'),\n label: z.string().min(1),\n base_url: z.string().url(),\n api_key: z.string().min(1),\n enabled: z.boolean().optional(),\n});\nexport const UpdateCustomProviderSchema = z.object({\n label: z.string().min(1).optional(),\n base_url: z.string().url().optional(),\n api_key: z.string().optional(),\n enabled: z.boolean().optional(),\n});\n```\n\n- [x] **Step 5: Custom-provider CRUD routes**\n\n`src/pages/api/admin/ai/custom-providers/index.ts` — `GET` returns\n`stripApiKeys(settings).custom_providers`; `POST` validates `CreateCustomProviderSchema` then\n`saveAiSettings(db, { custom_providers: { [id]: { label, base_url, api_key, enabled: enabled ?? true, models: [] } } })`.\n`src/pages/api/admin/ai/custom-providers/[id].ts` — `PUT` validates `UpdateCustomProviderSchema`\nthen `saveAiSettings(db, { custom_providers: { [id]: patch } })` (deep merge keeps key/models);\n`DELETE` loads settings, removes the id from `custom_providers`, writes the full object via a\ndedicated `replaceAiSettings` (full overwrite) — add `replaceAiSettings(db, settings)` to\n`settings-store.ts` that validates + writes without merge. Both require `AuthRoles.ADMIN` via\n`extractAuthContext`/`hasRole` (follow `migrate-models.ts` auth pattern).\n\n- [x] **Step 6: Verify + commit**\n\nRun: `pnpm test src/modules/ai/__tests__/openai-compatible.test.ts`\n→ Expected: PASS.\n```bash\ngit add src/modules/ai/providers/openai-compatible.provider.ts src/modules/ai/ai.service.ts src/modules/ai/settings-store.ts src/shared/validation/schemas/ai.ts src/pages/api/admin/ai/custom-providers src/modules/ai/__tests__/openai-compatible.test.ts\ngit commit -m \"feat(ai): OpenAI-compatible custom providers + CRUD\"\n```\n\n---\n\n## Phase 5 — discovery + settings + providers + generate endpoints (snake_case)\n\n**Files:**\n- Create: `src/pages/api/admin/ai/models/[provider]/discover.ts`\n- Modify: `src/pages/api/admin/ai/settings.ts`, `providers.ts`, `generate.ts`,\n `models/[provider].ts`, `models/[provider]/[modelId].ts`\n- Delete: `src/pages/api/admin/ai/migrate-models.ts`\n- Modify: `src/shared/validation/schemas/ai.ts` (snake_case settings/generate schemas)\n- Modify: `src/modules/ai/ai.service.ts` (`getModelsForProvider`, `generateContent`,\n `getConfiguredProviders`, resolution helper)\n\n- [x] **Step 1: Rewrite `ai.ts` request schemas to snake_case**\n\nUpdate `GenerateSchema`, `UpdateSettingsSchema`, `AddModelSchema`, `UpdateModelSchema`,\n`ProviderModelParam`, `ValidateApiKeySchema` to snake_case keys:\n```ts\nexport const GenerateSchema = z.object({\n prompt: z.string().min(3).max(10000),\n content_type: z.enum(['recipe', 'article', 'roundup']),\n provider: z.string().optional(),\n model: z.string().min(1).optional(),\n temperature: z.number().min(0).max(2).optional(),\n reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),\n}).passthrough();\n\nexport const UpdateSettingsSchema = z.object({\n default_provider: z.string().optional(),\n default_model: z.string().optional(),\n temperature: z.number().min(0).max(1).optional(),\n system_prompt: z.string().optional(),\n providers: z.record(z.string(), z.object({\n api_key: z.string().optional(),\n enabled: z.boolean().optional(),\n models: z.array(z.any()).optional(),\n })).optional(),\n}).passthrough();\n\nexport const AddModelSchema = z.object({\n id: z.string().min(1),\n name: z.string().min(1),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n}).passthrough();\n\nexport const UpdateModelSchema = z.object({\n name: z.string().min(1).optional(),\n context_window: z.number().int().positive().optional(),\n max_tokens: z.number().int().positive().optional(),\n deprecated: z.boolean().optional(),\n enabled: z.boolean().optional(),\n}).passthrough();\n\nexport const ProviderModelParam = z.object({ provider: z.string().min(1), model_id: z.string().min(1) });\nexport const ValidateApiKeySchema = z.object({ provider: z.string().min(1), api_key: z.string().min(1) });\n```\n(`ProviderEnum` is replaced by `z.string()` since custom providers exist; validity is checked\nagainst settings at runtime.)\n\n- [x] **Step 2: Discovery endpoint**\n\n`src/pages/api/admin/ai/models/[provider]/discover.ts` (`GET`, `AuthRoles.ADMIN`):\n```ts\n// resolve provider config (built-in providers[p] or custom_providers[p]) from getAiSettings\n// const cfg = settings.providers[provider] ?? settings.custom_providers[provider];\n// if (!cfg?.api_key) -> 400 \"No API key configured for \"\n// const impl = createProvider(provider, cfg.api_key, (cfg as CustomProviderConfig).base_url);\n// const { supported, models } = await impl.listModels(cfg.api_key);\n// const view = reconcileSelection(cfg.models ?? [], models);\n// return formatSuccessResponse({ supported, models: view });\n```\nUse `formatSuccessResponse`/`formatErrorResponse` and the `validateParams`-style param read used by\nsibling routes.\n\n- [x] **Step 3: `settings.ts` GET (masked) / PUT (deep merge)**\n\nGET: `formatSuccessResponse(stripApiKeys(await getAiSettings(db)))`.\nPUT: `validateBody(request, UpdateSettingsSchema)` → `saveAiSettings(db, body)` → return\n`stripApiKeys(await getAiSettings(db))`.\nPOST (validate key): `createProvider(provider, api_key, base_url?).validateApiKey(api_key)`.\nRemove every camelCase field; require `AuthRoles.ADMIN`.\n\n- [x] **Step 4: `providers.ts`, `generate.ts`, model CRUD to snake_case + selection**\n\n`providers.ts` GET → `{ configured_providers, available_models, provider_info, defaults }` where\n`available_models[p] = getModelsForProvider(db, p)` (stored selection, enabled only). Strip keys.\n`generate.ts` → read `GenerateSchema`, call `generateContent(db, { ...body })`, snake_case response.\n`models/[provider].ts` (POST add / GET list) and `models/[provider]/[modelId].ts` (PUT/DELETE) →\noperate on `providers[p].models` (or `custom_providers[p].models`) via `saveAiSettings`, snake_case\nfields, `source: 'manual'` for manually added models.\n\n- [x] **Step 5: `ai.service.ts` resolution + generate**\n\n```ts\nexport async function getModelsForProvider(db: D1Database, provider: AIProvider): Promise {\n const settings = await getAiSettings(db);\n const cfg = settings.providers[provider as BuiltInProvider] ?? settings.custom_providers[provider];\n return (cfg?.models ?? []).filter(m => m.enabled && m.status !== 'unavailable');\n}\n\nexport async function generateContent(db: D1Database, request: GenerateContentRequest): Promise {\n const settings = await getAiSettings(db);\n const provider = request.provider || settings.default_provider;\n const cfg = settings.providers[provider as BuiltInProvider] ?? settings.custom_providers[provider];\n if (!cfg?.api_key) return { success: false, error: `No API key configured for provider: ${provider}` };\n if (!cfg.enabled) return { success: false, error: `Provider is disabled: ${provider}` };\n const impl = createProvider(provider, cfg.api_key, (cfg as CustomProviderConfig).base_url);\n const model = request.model || (provider === settings.default_provider ? settings.default_model : request.model);\n return impl.generateContent({\n ...request,\n model: model || request.model,\n temperature: request.temperature ?? settings.temperature,\n system_prompt: request.system_prompt || settings.system_prompt,\n });\n}\n```\nUpdate `getConfiguredProviders` to iterate `providers` + `custom_providers` reading `api_key`/`enabled`.\n\n- [x] **Step 6: Delete the obsolete migration endpoint**\n\n```bash\ngit rm src/pages/api/admin/ai/migrate-models.ts\n```\nThen `rg -n \"migrate-models|AVAILABLE_MODELS\" src` and remove every remaining reference.\n\n- [x] **Step 7: Verify + commit**\n\nRun: `pnpm test && pnpm check:boundaries`\nExpected: PASS / \"Boundary check passed.\" (`pnpm typecheck` may still be red until admin UI in\nPhase 7.)\n```bash\ngit add -A\ngit commit -m \"feat(ai): discovery + snake_case settings/providers/generate endpoints; drop migrate-models\"\n```\n\n---\n\n## Phase 6 — thinking / reasoning mapping at the provider boundary\n\n**Files:**\n- Modify: providers that support reasoning — `openai`, `anthropic`, `deepseek`, `openai-compatible`\n- Modify: `src/modules/ai/__tests__/...` (one mapping test)\n\n- [x] **Step 1: Map `reasoning_effort` per provider at the fetch boundary**\n\nIn `openai.provider.ts` and `openai-compatible.provider.ts`, when `request.reasoning_effort` is set,\nadd `reasoning_effort: request.reasoning_effort` to the request body. In `anthropic.provider.ts`,\nmap to `thinking: { type: 'enabled', budget_tokens: }`. In\n`deepseek.provider.ts`, no body change is required (reasoner is model-selected) — leave a comment.\nProviders without support ignore the field (no change).\n\n- [x] **Step 2: Test the mapping (openai body includes reasoning_effort)**\n\nAdd to an existing/new provider test that mocks `fetch` and asserts the request body passed to\n`fetch` contains `reasoning_effort: 'high'` when set. Run that test → Expected: PASS.\n\n- [x] **Step 3: Commit**\n\n```bash\ngit add src/modules/ai/providers src/modules/ai/__tests__\ngit commit -m \"feat(ai): map reasoning_effort to provider-native thinking params\"\n```\n\n---\n\n## Phase 7 — admin UI: snake_case + discovery UX + has_api_key\n\n**Files:**\n- Modify: `src/admin/services/api.ts` (AI client methods)\n- Modify: `src/admin/components/ModelManager.tsx`, `BulkImportModels.tsx`\n- Modify: `src/admin/features/settings/pages/tabs/AISettings.tsx`,\n `src/admin/components/BlockEditor/components/AISettings.tsx`,\n `src/admin/features/settings/pages/Settings.tsx`\n\n- [x] **Step 1: `api.ts` AI client methods → snake_case + discover + custom providers**\n\nAdd/repoint methods: `aiSettings.get/update`, `aiProviders.get`, `aiModels.discover(provider)`,\n`aiModels.add/update/remove`, `aiCustomProviders.list/create/update/remove`, `aiGenerate(payload)`.\nAll payloads/reads use snake_case (`content_type`, `reasoning_effort`, `default_provider`,\n`has_api_key`, `context_window`, `max_tokens`).\n\n- [x] **Step 2: ModelManager — discovery checklist**\n\nRework `ManagedModel` to snake_case (`context_window`, `max_tokens`, `enabled`, `deprecated`,\n`status`). Add a \"Fetch latest models\" button calling `aiModels.discover(provider)`; render the\nreturned `models` as a checklist with `status` badges (`unavailable`/`deprecated`); ticking a model\nsaves it into the selection via `aiSettings.update`/`aiModels.add`. Keep manual add + bulk import\nvisible as fallback (and shown automatically when `discover` returns `supported: false`).\n\n- [x] **Step 3: BulkImportModels + both AISettings tabs + Settings.tsx**\n\nRename all AI data-key reads/writes to snake_case (`context_window`, `max_tokens`, `api_key`→write\nonly, render `has_api_key`/`api_key_masked` instead of the raw key, `default_provider`,\n`default_model`, `system_prompt`, `reasoning_effort` selector shown when the chosen model has\n`supports_thinking`). Add a custom-provider add/edit form (id/label/base_url/api_key) backed by\n`aiCustomProviders`.\n\n- [x] **Step 4: Verify full green**\n\nRun: `pnpm typecheck && pnpm test && pnpm check:boundaries`\nExpected: all green (this is the phase where typecheck returns fully clean).\n\n- [x] **Step 5: Commit**\n\n```bash\ngit add src/admin\ngit commit -m \"feat(ai): snake_case admin AI UI + model discovery UX + write-only keys\"\n```\n\n---\n\n## Phase 8 — one-shot stored-blob data migration\n\n**Files:**\n- Create: `scripts/migrate-ai-settings.mts`\n- Create: `src/modules/ai/__tests__/migrate-ai-settings.test.ts`\n\n- [x] **Step 1: Failing test for the pure transform**\n\n`src/modules/ai/__tests__/migrate-ai-settings.test.ts`:\n```ts\nimport { describe, it, expect } from 'vitest';\nimport { migrateAiSettingsBlob } from '../../../scripts/migrate-ai-settings.mts';\n\ndescribe('migrateAiSettingsBlob', () => {\n it('converts legacy camelCase blob to snake_case selection shape', () => {\n const legacy = {\n defaultProvider: 'openai', defaultModel: 'gpt-4o', temperature: 0.7, systemPrompt: 'sp',\n providers: { openai: { apiKey: 'sk-1', enabled: true,\n availableModels: [{ id: 'gpt-4o', name: 'GPT-4o', contextWindow: 131072, maxTokens: 65536, enabled: true }] } },\n };\n const out = migrateAiSettingsBlob(legacy);\n expect(out.default_provider).toBe('openai');\n expect(out.system_prompt).toBe('sp');\n expect(out.providers.openai.api_key).toBe('sk-1');\n expect(out.providers.openai.models[0]).toMatchObject({\n id: 'gpt-4o', context_window: 131072, max_tokens: 65536, modality: 'text',\n status: 'available', source: 'discovered', enabled: true,\n });\n expect(out.custom_providers).toEqual({});\n });\n});\n```\n\nRun: `pnpm test src/modules/ai/__tests__/migrate-ai-settings.test.ts` → Expected: FAIL.\n\n- [x] **Step 2: Implement `scripts/migrate-ai-settings.mts`**\n\nExport a pure `migrateAiSettingsBlob(legacy)` (camelCase → snake_case, `availableModels` → `models`\nwith `modality:'text'`, `status:'available'`, `source:'discovered'`, `order` by index) plus a\n`main()` that opens the local D1 sqlite (reuse the `findDb` pattern from\n`scripts/migrate-credit-avatar-r2key.mts`), reads `site_settings.value WHERE key='ai_settings'`,\nprints a dry-run diff, and writes the transformed blob under `--apply`. Validate the result with\n`AiSettingsSchema` before writing.\n\nRun: `pnpm test src/modules/ai/__tests__/migrate-ai-settings.test.ts` → Expected: PASS.\n\n- [x] **Step 3: Dry-run against local D1**\n\nRun: `node_modules/.bin/tsx scripts/migrate-ai-settings.mts`\nExpected: prints the would-be snake_case blob; no write. (If no `ai_settings` row exists locally,\nit prints \"no ai_settings row — nothing to migrate\".)\n\n- [x] **Step 4: Commit**\n\n```bash\ngit add scripts/migrate-ai-settings.mts src/modules/ai/__tests__/migrate-ai-settings.test.ts\ngit commit -m \"chore(ai): one-shot migration of stored ai_settings to snake_case selection shape\"\n```\n\n---\n\n## Phase 9 — finalize & close AI portion of #3\n\n**Files:**\n- Modify: `docs/NAMING_CONTRACT.md` (only if it still implies AI is pending)\n- Modify: `.hermes/plans/2026-06-03_contract-audit-report.md` (local record)\n\n- [x] **Step 1: Full regression gate**\n\nRun: `pnpm typecheck && pnpm test && pnpm check:boundaries`\nExpected: all green.\n\n- [x] **Step 2: Contract audit — no AI camelCase regressions**\n\nRun: `rg -n \"\\b(defaultProvider|defaultModel|systemPrompt|apiKey|availableModels|contextWindow|maxTokens|modelId)\\b\" src/modules/ai src/pages/api/admin/ai src/admin --glob '!**/*.test.*'`\nExpected: matches only inside provider classes at the external fetch boundary (allowed exception)\nand local variables — no app-owned data-shape keys. Then\n`node scripts/local-contract-audit.mjs --summary` → no new camelCase violation.\n\n- [x] **Step 3: Commit (if docs changed)**\n\n```bash\ngit add docs/NAMING_CONTRACT.md\ngit commit -m \"docs(naming): AI subsystem migrated to snake_case (audit #3)\"\n```\n\n---\n\n## Self-review notes\n\n- **Spec coverage:** stored shape (P0/P1/P5), discovery `listModels` (P2/P3), discover endpoint\n (P5), custom providers (P4), safe persistence + Zod (P1), key masking (P1/P5), obsolescence\n reconcile (P2 + P5 resolution filter), thinking (P6), modality filter (P2 normalizer), snake_case\n E2E (P0/P5/P7), data migration (P8), cleanup of AVAILABLE_MODELS + migrate-models (P5). ✓\n- **No placeholders:** pure-logic units ship full code + tests; mechanical endpoint/UI tasks give\n exact files, exact snake_case key lists, and a shared normalizer (DRY) rather than repeated\n provider code. ✓\n- **Type consistency:** `AiSettings`, `ModelSelection`, `DiscoveredModel`, `ListModelsResult`,\n `reconcileSelection`, `mergeAiSettings`, `getAiSettings`/`saveAiSettings`/`replaceAiSettings`,\n `stripApiKeys`/`maskApiKey`, `normalizeOpenAiModels`/`classifyModality`/`detectThinking` are used\n with consistent names across phases. ✓\n- **Typecheck phasing:** Phases 1, 2, 3, 8 are green in isolation; Phases 0, 4–7 may leave global\n typecheck red until Phase 7 (documented in the Phase 0 note); every phase's own unit tests pass.\n"}}}}