diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index e104996..444399a 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets", got) } } @@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") @@ -309,3 +309,90 @@ func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) { t.Error("jobs_new left behind") } } + +// TestMigrateAIProviderPresetsKeepsRowsAndReferences upgrades a database +// holding ai_settings and ai_runs rows, with suggestion_batches and +// week_plans referencing the run, and checks 0004 carries every row over, +// keeps the references enforced and accepts the new providers. +func TestMigrateAIProviderPresetsKeepsRowsAndReferences(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + before := fstest.MapFS{} + for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql"} { + body, err := fs.ReadFile(migrations.FS, name) + if err != nil { + t.Fatal(err) + } + before[name] = &fstest.MapFile{Data: body} + } + if err := Migrate(ctx, s.Writer, before); err != nil { + t.Fatal(err) + } + for _, stmt := range []string{ + `INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at) + VALUES (1, 'anthropic', 'https://api.anthropic.com', 'claude', 0.7, 'sp', x'0102', 'abcd', 'v', 'c', 'u')`, + `INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at) + VALUES ('run-1', 'suggest', '1', 'ollama', 'llama', 0.5, 'ok', 10, 20, 30, 'e', 'req', 'resp', 'c', 'u')`, + `INSERT INTO suggestion_batches (id, trigger, status, ai_run_id, created_at, updated_at) + VALUES ('batch-1', 'manual', 'done', 'run-1', 'c', 'u')`, + `INSERT INTO week_plans (id, week_start, status, ai_run_id, created_at, updated_at) + VALUES ('plan-1', '2026-10-04', 'draft', 'run-1', 'c', 'u')`, + } { + if _, err := s.Writer.Exec(stmt); err != nil { + t.Fatalf("seed: %v", err) + } + } + + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + + var provider, baseURL, model, systemPrompt, last4, verifiedAt, createdAt, updatedAt string + var temperature float64 + var keyCT []byte + if err := s.Reader.QueryRow(`SELECT provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings WHERE id = 1`). + Scan(&provider, &baseURL, &model, &temperature, &systemPrompt, &keyCT, &last4, &verifiedAt, &createdAt, &updatedAt); err != nil { + t.Fatalf("read ai_settings: %v", err) + } + settings := []any{provider, baseURL, model, temperature, systemPrompt, string(keyCT), last4, verifiedAt, createdAt, updatedAt} + if want := []any{"anthropic", "https://api.anthropic.com", "claude", 0.7, "sp", "\x01\x02", "abcd", "v", "c", "u"}; !slices.Equal(settings, want) { + t.Errorf("ai_settings = %v, want %v", settings, want) + } + + var skill, skillVersion, status, errText, request, response string + var latency, tokensIn, tokensOut int + if err := s.Reader.QueryRow(`SELECT skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at FROM ai_runs WHERE id = 'run-1'`). + Scan(&skill, &skillVersion, &provider, &model, &temperature, &status, &latency, &tokensIn, &tokensOut, &errText, &request, &response, &createdAt, &updatedAt); err != nil { + t.Fatalf("read ai_runs: %v", err) + } + run := []any{skill, skillVersion, provider, model, temperature, status, latency, tokensIn, tokensOut, errText, request, response, createdAt, updatedAt} + if want := []any{"suggest", "1", "ollama", "llama", 0.5, "ok", 10, 20, 30, "e", "req", "resp", "c", "u"}; !slices.Equal(run, want) { + t.Errorf("ai_runs = %v, want %v", run, want) + } + + rows, err := s.Reader.Query("PRAGMA foreign_key_check") + if err != nil { + t.Fatal(err) + } + if rows.Next() { + t.Error("foreign_key_check reported violations after 0004") + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + if _, err := s.Writer.Exec(`DELETE FROM ai_runs WHERE id = 'run-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") { + t.Errorf("deleting a referenced ai_run: error %v, want FOREIGN KEY constraint failure", err) + } + + if _, err := s.Writer.Exec(`UPDATE ai_settings SET provider = 'openrouter' WHERE id = 1`); err != nil { + t.Errorf("ai_settings.provider openrouter rejected: %v", err) + } + if _, err := s.Writer.Exec(`UPDATE ai_runs SET provider = 'openai' WHERE id = 'run-1'`); err != nil { + t.Errorf("ai_runs.provider openai rejected: %v", err) + } + for _, table := range []string{"ai_settings_copy", "ai_runs_copy"} { + if tableExists(t, s, table) { + t.Errorf("%s left behind", table) + } + } +} diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index 2b36ad1..1ce39a4 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -305,7 +305,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) { {"household_settings", "plan_draft_dow", 5.5}, {"household_settings", "suggestions_dow", 0.5}, {"household_settings", "chat_autosend_dow", 3.5}, - {"ai_runs", "provider", "openai"}, + {"ai_runs", "provider", "gemini"}, {"ai_runs", "status", "done"}, {"dinners", "status", "deck"}, {"dinners", "origin", "ai"}, @@ -322,7 +322,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) { {"rotation_reviews", "decision", "promote"}, {"shopping_lists", "status", "frozen"}, {"shopping_items", "needed", 2}, - {"ai_settings", "provider", "openai"}, + {"ai_settings", "provider", "gemini"}, {"recipe_search_settings", "provider", "google"}, {"chat_settings", "last_test_status", "pending"}, {"notification_prefs", "event", "weekly_digest"}, @@ -360,6 +360,23 @@ func TestSchemaRotationReviewEventAllowed(t *testing.T) { } } +// TestSchemaAIProvidersAllowed checks every provider preset can be stored in +// both ai_settings and ai_runs. +func TestSchemaAIProvidersAllowed(t *testing.T) { + s := seededStore(t) + for _, provider := range []string{"openai_compatible", "anthropic", "ollama", "openai", "openrouter"} { + for _, table := range []string{"ai_settings", "ai_runs"} { + inRolledBackTx(t, s, func(tx *sql.Tx) { + row := testRow(table, 1) + row["provider"] = provider + if err := insertSchemaRow(tx, table, row); err != nil { + t.Errorf("%s.provider %s rejected: %v", table, provider, err) + } + }) + } + } +} + func TestSchemaDinnerStatuses(t *testing.T) { s := seededStore(t) for _, status := range []string{"suggested", "quest_log", "tried", "regular", "archived"} { diff --git a/migrations/0004_ai_provider_presets.sql b/migrations/0004_ai_provider_presets.sql new file mode 100644 index 0000000..965ca79 --- /dev/null +++ b/migrations/0004_ai_provider_presets.sql @@ -0,0 +1,68 @@ +-- 0004_ai_provider_presets: ai_settings.provider and ai_runs.provider become +-- openai_compatible, anthropic, ollama, openai, openrouter, the presets in +-- internal/ai/llm/presets. Every 0001 value is kept, so existing rows copy +-- unchanged. SQLite cannot change a CHECK in place, so both tables are +-- rebuilt: copy the rows aside, drop the table, create it again with the +-- wider CHECK, copy the rows back and drop the copy. Neither table has +-- indexes or triggers. +-- +-- suggestion_batches.ai_run_id and week_plans.ai_run_id reference ai_runs, +-- so the rebuild cannot use 0003's create-new-then-rename: dropping ai_runs +-- counts every referencing row as a foreign key violation, and only inserting +-- the matching ai_runs rows under the name ai_runs clears them. Deferring +-- foreign keys holds the check until commit, after the rows are back. The +-- pragma lasts only for this migration's transaction. + +PRAGMA defer_foreign_keys = ON; + +CREATE TABLE ai_settings_copy AS SELECT * FROM ai_settings; + +DROP TABLE ai_settings; + +CREATE TABLE ai_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter')), + base_url TEXT NOT NULL, + model TEXT NOT NULL, + temperature REAL NOT NULL, + system_prompt TEXT, + api_key_ct BLOB, + api_key_last4 TEXT, + key_verified_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO ai_settings (id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at) +SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at +FROM ai_settings_copy; + +DROP TABLE ai_settings_copy; + +CREATE TABLE ai_runs_copy AS SELECT * FROM ai_runs; + +DROP TABLE ai_runs; + +CREATE TABLE ai_runs ( + id TEXT NOT NULL PRIMARY KEY, + skill TEXT NOT NULL, + skill_version TEXT NOT NULL, + provider TEXT NOT NULL CHECK (provider IN ('openai_compatible', 'anthropic', 'ollama', 'openai', 'openrouter')), + model TEXT NOT NULL, + temperature REAL NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error')), + latency_ms INTEGER, + tokens_in INTEGER, + tokens_out INTEGER, + error TEXT, + request_redacted TEXT NOT NULL, + response_redacted TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO ai_runs (id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at) +SELECT id, skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in, tokens_out, error, request_redacted, response_redacted, created_at, updated_at +FROM ai_runs_copy; + +DROP TABLE ai_runs_copy;