Implement OpenRouter Model Support in AI Prompt System
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s

- Added `openrouter_model` field to the `ai_prompts` table, allowing for optional model overrides per prompt.
- Updated the `exercise_ai` module to utilize the effective OpenRouter model based on prompt-specific settings, enhancing flexibility in AI interactions.
- Enhanced the admin interface to support OpenRouter model configuration for prompts, improving usability for Superadmins.
- Incremented application version to 0.8.161 and updated changelog to reflect these changes, including migration details and new functionality.
This commit is contained in:
Lars 2026-05-22 12:37:43 +02:00
parent 0551bb3d80
commit 93b8d09d05
11 changed files with 117 additions and 24 deletions

View File

@ -8,6 +8,7 @@
**Ist-Stand API (Superadmin):** **Ist-Stand API (Superadmin):**
- `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders` - `GET /api/admin/ai-prompts`, `GET /api/admin/ai-prompts/{id}`, `PUT …`, `POST …/preview`, `POST …/reset-template`, `GET /api/admin/ai-prompts/catalog/placeholders`
- Spalte **`openrouter_model`** (Migration **070**): Optional pro Prompt-Zeile; OpenRouter **`model`**-Parameter; **`NULL`/leer ⇒ `OPENROUTER_MODEL`** aus der Umgebung.
**Autor:** Claude Code **Autor:** Claude Code
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System **Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System

View File

@ -140,7 +140,7 @@ flowchart LR
|-------|--------| |-------|--------|
| **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. | | **P0** | `AiPromptContextKind`, `load_ai_prompt_row` zentral; Übungs-KI über Laufzeit. |
| **P1 (teilweise)** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **Pydantic** `ExerciseFormAiPromptContext` / `resolve_exercise_form_variables` in `ai_prompt_job` (Admin-Vorschau + gemeinsame Schnittstelle). Nächster Schritt: `POST /exercises/ai/suggest` kann dasselbe Kontextmodell nutzen; optionale `execute_*`-Fassade fuer OpenRouter-Schritt. | | **P1 (teilweise)** | `load_and_render_ai_prompt`, `AiPromptUnavailableError`, `render_ai_prompt_template_for_row`; **Pydantic** `ExerciseFormAiPromptContext` / `resolve_exercise_form_variables` in `ai_prompt_job` (Admin-Vorschau + gemeinsame Schnittstelle). Nächster Schritt: `POST /exercises/ai/suggest` kann dasselbe Kontextmodell nutzen; optionale `execute_*`-Fassade fuer OpenRouter-Schritt. |
| **P2** | Versionierung oder Audit-Spalten; optionale Modell-/Temperatur-Overrides pro Slug in DB oder Config-Tabelle. | | **P2** | Versionierung oder Audit-Spalten; **teilweise:** optionales OpenRouter-Modell pro Zeile (`openrouter_model`, Migration 070, Fallback `OPENROUTER_MODEL`); weitere Overrides (Temperatur) offen. |
| **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. | | **P3** | Composition/Segmente (JSON Schema Version 1) + UI nur für komplexe Slugs. |
| **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. | | **P4** | Erste Planungs-/Rahmen-Slugs mit dedizierten Buildern und Token-Budget-Strategien. |

View File

@ -34,6 +34,8 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
OPENROUTER_API_KEY=your_api_key_here OPENROUTER_API_KEY=your_api_key_here
OPENROUTER_MODEL=anthropic/claude-sonnet-4 OPENROUTER_MODEL=anthropic/claude-sonnet-4
# Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model
# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“).
# Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container. # Übungs-KI (Docker): ohne Eintrag im compose „environment:“ landet keine .env-Zeile im Container.
# Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter). # Hier ist SHINKAN_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs (exercise_ai, openrouter).

View File

@ -24,6 +24,10 @@ class ExerciseFormAiPromptContext(BaseModel):
""" """
Eingabe fuer Uebungsbezogene Prompts (Kurzfassung / Skill-JSON-Vorschlag). Eingabe fuer Uebungsbezogene Prompts (Kurzfassung / Skill-JSON-Vorschlag).
Entspricht fachlich dem Preview-Body unter /api/admin/ai-prompts/*/preview. Entspricht fachlich dem Preview-Body unter /api/admin/ai-prompts/*/preview.
Abgrenzung: POST /exercises/ai/suggest nutzt ExerciseAiSuggestBody mit include_summary /
include_skills usw.; dieses Modell bildet nur die gemeinsamen Formularfelder wie die
Admin-Vorschau-Body (AiPromptPreviewBody).
""" """
title: Optional[str] = "" title: Optional[str] = ""

View File

@ -45,7 +45,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
if active_only: if active_only:
cur.execute( cur.execute(
""" """
SELECT slug, display_name, template, output_format, active SELECT slug, display_name, template, output_format, active, openrouter_model
FROM ai_prompts FROM ai_prompts
WHERE slug = %s AND active = true WHERE slug = %s AND active = true
""", """,
@ -54,7 +54,7 @@ def load_ai_prompt_row(cur, slug: str, *, active_only: bool = True) -> Optional[
else: else:
cur.execute( cur.execute(
""" """
SELECT slug, display_name, template, output_format, active SELECT slug, display_name, template, output_format, active, openrouter_model
FROM ai_prompts FROM ai_prompts
WHERE slug = %s WHERE slug = %s
""", """,

View File

@ -16,7 +16,13 @@ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence,
from fastapi import HTTPException from fastapi import HTTPException
from openrouter_chat import OpenRouterError, normalize_openrouter_env, openrouter_chat_completion from openrouter_chat import (
OpenRouterError,
default_openrouter_model_id,
effective_openrouter_model_for_prompt_row,
normalize_openrouter_env,
openrouter_chat_completion,
)
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
@ -663,14 +669,14 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
return out[:5] return out[:5]
def _require_openrouter() -> Tuple[str, str]: def _require_openrouter_key() -> str:
key, model = normalize_openrouter_env() key, _ = normalize_openrouter_env()
if not key: if not key:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).", detail="KI nicht konfiguriert (OPENROUTER_API_KEY fehlt).",
) )
return key, model return key
def run_exercise_ai_suggestion( def run_exercise_ai_suggestion(
@ -684,7 +690,7 @@ def run_exercise_ai_suggestion(
want_summary: bool, want_summary: bool,
want_skills: bool, want_skills: bool,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
key, model = _require_openrouter() key = _require_openrouter_key()
g_plain = strip_html_to_plain(goal) g_plain = strip_html_to_plain(goal)
e_plain = strip_html_to_plain(execution) e_plain = strip_html_to_plain(execution)
@ -697,7 +703,8 @@ def run_exercise_ai_suggestion(
t_title = (title or "").strip() t_title = (title or "").strip()
focus = (focus_area_hint or "").strip() focus = (focus_area_hint or "").strip()
result: Dict[str, Any] = {"model": model} result: Dict[str, Any] = {}
models_by_slug: Dict[str, str] = {}
if _ai_debug_on(): if _ai_debug_on():
fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context)) fid_list = ",".join(str(x) for x in _ordered_focus_ids(focus_areas_context))
@ -733,6 +740,8 @@ def run_exercise_ai_suggestion(
status_code=503, status_code=503,
detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.", detail="Prompt exercise_summary nicht aktiv oder fehlt in DB.",
) from None ) from None
model_summary = effective_openrouter_model_for_prompt_row(prow)
models_by_slug["exercise_summary"] = model_summary
prompt = rendered.text prompt = rendered.text
if _ai_debug_on(): if _ai_debug_on():
_LOGGER.warning( _LOGGER.warning(
@ -741,7 +750,7 @@ def run_exercise_ai_suggestion(
len(rendered.placeholders_remaining), len(rendered.placeholders_remaining),
) )
try: try:
raw = openrouter_chat_completion(api_key=key, model=model, user_content=prompt) raw = openrouter_chat_completion(api_key=key, model=model_summary, user_content=prompt)
except OpenRouterError as e: except OpenRouterError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
if _ai_debug_on(): if _ai_debug_on():
@ -754,7 +763,7 @@ def run_exercise_ai_suggestion(
) )
if len(text) > _MAX_SUMMARY_CHARS: if len(text) > _MAX_SUMMARY_CHARS:
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "" text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + ""
result["summary"] = {"text": text, "ai_generated": True, "model": model} result["summary"] = {"text": text, "ai_generated": True, "model": model_summary}
if want_skills: if want_skills:
try: try:
@ -776,6 +785,8 @@ def run_exercise_ai_suggestion(
status_code=503, status_code=503,
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.", detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
) from None ) from None
model_skills = effective_openrouter_model_for_prompt_row(srow)
models_by_slug["exercise_skill_suggestions"] = model_skills
prompt = rendered.text prompt = rendered.text
if _ai_debug_on(): if _ai_debug_on():
_LOGGER.warning( _LOGGER.warning(
@ -791,7 +802,7 @@ def run_exercise_ai_suggestion(
try: try:
raw = openrouter_chat_completion( raw = openrouter_chat_completion(
api_key=key, api_key=key,
model=model, model=model_skills,
user_content=prompt, user_content=prompt,
system_content=sys_hint, system_content=sys_hint,
temperature=0.15, temperature=0.15,
@ -829,6 +840,14 @@ def run_exercise_ai_suggestion(
result["skills"] = skills result["skills"] = skills
result["models_by_slug"] = models_by_slug
if want_skills:
result["model"] = models_by_slug["exercise_skill_suggestions"]
elif want_summary:
result["model"] = models_by_slug["exercise_summary"]
else:
result["model"] = default_openrouter_model_id()
return result return result

View File

@ -0,0 +1,7 @@
-- Migration 070: optionales OpenRouter-Modell pro Prompt-Zeile
-- Leer/NULL → Umgebungsvariable OPENROUTER_MODEL (wie bisher).
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS openrouter_model VARCHAR(200);
COMMENT ON COLUMN ai_prompts.openrouter_model IS
'Optional: OpenRouter model id (z.B. anthropic/claude-3.5-haiku); NULL = OPENROUTER_MODEL aus Env';

View File

@ -6,7 +6,7 @@ from __future__ import annotations
import json import json
import logging import logging
import os import os
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Mapping, Optional
import httpx import httpx
@ -203,3 +203,22 @@ def normalize_openrouter_env() -> tuple[str, str]:
key = (os.getenv("OPENROUTER_API_KEY") or "").strip() key = (os.getenv("OPENROUTER_API_KEY") or "").strip()
model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip() model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip()
return key, model return key, model
def default_openrouter_model_id() -> str:
"""Standard-Modell aus OPENROUTER_MODEL (ohne API-Key zu pruefen)."""
_, model = normalize_openrouter_env()
return model
def effective_openrouter_model_for_prompt_row(row: Optional[Mapping[str, Any]]) -> str:
"""
Pro-Prompt-Override in ai_prompts.openrouter_model, sonst Env-Default.
`row` kann eine partial Row aus load_ai_prompt_row sein (Felder slug, openrouter_model, ).
"""
if row:
custom = str(row.get("openrouter_model") or "").strip()
if custom:
return custom
return default_openrouter_model_id()

View File

@ -40,7 +40,7 @@ def _fetch_prompt_any(cur, prompt_id: int) -> Dict[str, Any]:
cur.execute( cur.execute(
""" """
SELECT id, slug, display_name, description, template, category, output_format, SELECT id, slug, display_name, description, template, category, output_format,
output_schema, is_system_default, default_template, output_schema, is_system_default, default_template, openrouter_model,
active, sort_order, created_at, updated_at active, sort_order, created_at, updated_at
FROM ai_prompts WHERE id = %s FROM ai_prompts WHERE id = %s
""", """,
@ -57,6 +57,7 @@ class AiPromptUpdateBody(BaseModel):
active: Optional[bool] = None active: Optional[bool] = None
display_name: Optional[str] = Field(None, max_length=200) display_name: Optional[str] = Field(None, max_length=200)
description: Optional[str] = Field(None, max_length=8000) description: Optional[str] = Field(None, max_length=8000)
openrouter_model: Optional[str] = Field(None, max_length=200)
class AiPromptPreviewFocus(BaseModel): class AiPromptPreviewFocus(BaseModel):
@ -86,7 +87,7 @@ def list_ai_prompts(session: dict = Depends(_require_superadmin)):
cur.execute( cur.execute(
""" """
SELECT id, slug, display_name, description, category, output_format, active, SELECT id, slug, display_name, description, category, output_format, active,
sort_order, is_system_default, default_template sort_order, is_system_default, default_template, openrouter_model
FROM ai_prompts FROM ai_prompts
ORDER BY sort_order ASC NULLS LAST, id ASC ORDER BY sort_order ASC NULLS LAST, id ASC
""" """
@ -150,16 +151,25 @@ def update_ai_prompt(
next_desc = body.description if body.description is not None else old.get("description") or "" next_desc = body.description if body.description is not None else old.get("description") or ""
next_desc = (next_desc or "").strip() next_desc = (next_desc or "").strip()
next_openrouter = old.get("openrouter_model")
if body.openrouter_model is not None:
cand = body.openrouter_model.strip() if isinstance(body.openrouter_model, str) else ""
if any(c in cand for c in ("\r", "\n", "\t")):
raise HTTPException(status_code=400, detail="openrouter_model: keine Steuerzeichen erlaubt.")
next_openrouter = cand or None
cur.execute( cur.execute(
""" """
UPDATE ai_prompts UPDATE ai_prompts
SET template = %s, active = %s, display_name = %s, description = %s, updated_at = NOW() SET template = %s, active = %s, display_name = %s, description = %s,
openrouter_model = %s, updated_at = NOW()
WHERE id = %s WHERE id = %s
RETURNING id, slug, display_name, description, template, category, output_format, RETURNING id, slug, display_name, description, template, category, output_format,
output_schema, is_system_default, default_template, active, sort_order, output_schema, is_system_default, default_template, openrouter_model,
active, sort_order,
created_at, updated_at created_at, updated_at
""", """,
(next_template, next_active, next_name, next_desc, prompt_id), (next_template, next_active, next_name, next_desc, next_openrouter, prompt_id),
) )
row = dict(cur.fetchone()) row = dict(cur.fetchone())
conn.commit() conn.commit()
@ -188,7 +198,8 @@ def reset_ai_prompt_template(prompt_id: int, session: dict = Depends(_require_su
SET template = default_template, updated_at = NOW() SET template = default_template, updated_at = NOW()
WHERE id = %s AND default_template IS NOT NULL WHERE id = %s AND default_template IS NOT NULL
RETURNING id, slug, display_name, description, template, category, output_format, RETURNING id, slug, display_name, description, template, category, output_format,
output_schema, is_system_default, default_template, active, sort_order, output_schema, is_system_default, default_template, openrouter_model,
active, sort_order,
created_at, updated_at created_at, updated_at
""", """,
(prompt_id,), (prompt_id,),

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.160" APP_VERSION = "0.8.161"
BUILD_DATE = "2026-05-30" BUILD_DATE = "2026-05-31"
DB_SCHEMA_VERSION = "20260530069" DB_SCHEMA_VERSION = "20260531070"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -19,14 +19,14 @@ MODULE_VERSIONS = {
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold) "media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets "media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068) "admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
"admin_ai_prompts": "1.0.2", # Preview: ai_prompt_job + render_ai_prompt_template_for_row "admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
"ai_prompt_job": "0.1.0", # ExerciseFormAiPromptContext, resolve_exercise_form_variables — P1 Kontext-Schnittstelle "ai_prompt_job": "0.1.0", # ExerciseFormAiPromptContext, resolve_exercise_form_variables — P1 Kontext-Schnittstelle
"ai_prompt_runtime": "0.2.0", # load_and_render_ai_prompt, AiPromptUnavailableError, render_ai_prompt_template_for_row "ai_prompt_runtime": "0.2.0", # load_and_render_ai_prompt, AiPromptUnavailableError, render_ai_prompt_template_for_row
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.31.2", # exercise_ai: load_and_render_ai_prompt statt getrennt load+render "exercises": "2.31.3", # exercise_ai: OpenRouter-Modell pro Prompt-Slug; Response models_by_slug
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -41,6 +41,15 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.161",
"date": "2026-05-31",
"changes": [
"Migration 070: ai_prompts.openrouter_model (optional je Prompt; Fallback OPENROUTER_MODEL).",
"exercise_ai: effektives OpenRouter-Modell pro Slug; API-Response models_by_slug + model (Skills bevorzugt).",
"Superadmin „KI Prompts“: OpenRouter-Modell speicherbar.",
],
},
{ {
"version": "0.8.160", "version": "0.8.160",
"date": "2026-05-30", "date": "2026-05-30",

View File

@ -23,6 +23,7 @@ export default function AdminAiPromptsPage() {
const [draftName, setDraftName] = useState('') const [draftName, setDraftName] = useState('')
const [draftDesc, setDraftDesc] = useState('') const [draftDesc, setDraftDesc] = useState('')
const [draftTemplate, setDraftTemplate] = useState('') const [draftTemplate, setDraftTemplate] = useState('')
const [draftOpenrouterModel, setDraftOpenrouterModel] = useState('')
const [draftActive, setDraftActive] = useState(true) const [draftActive, setDraftActive] = useState(true)
const [pvTitle, setPvTitle] = useState('Testübung') const [pvTitle, setPvTitle] = useState('Testübung')
@ -74,6 +75,9 @@ export default function AdminAiPromptsPage() {
setDraftName(d.display_name || '') setDraftName(d.display_name || '')
setDraftDesc(d.description || '') setDraftDesc(d.description || '')
setDraftTemplate(d.template || '') setDraftTemplate(d.template || '')
setDraftOpenrouterModel(
typeof d.openrouter_model === 'string' ? d.openrouter_model : ''
)
setDraftActive(!!d.active) setDraftActive(!!d.active)
setPvPreview(null) setPvPreview(null)
} }
@ -96,6 +100,7 @@ export default function AdminAiPromptsPage() {
display_name: draftName, display_name: draftName,
description: draftDesc, description: draftDesc,
active: draftActive, active: draftActive,
openrouter_model: draftOpenrouterModel.trim(),
}) })
await loadList() await loadList()
const nd = await api.getAdminAiPrompt(detail.id) const nd = await api.getAdminAiPrompt(detail.id)
@ -201,6 +206,11 @@ export default function AdminAiPromptsPage() {
inaktiv inaktiv
</span> </span>
) : null} ) : null}
{p.openrouter_model ? (
<span style={{ fontSize: 11, color: 'var(--text3)' }} title="OpenRouter-Modell für diesen Prompt">
Model: <code>{p.openrouter_model}</code>
</span>
) : null}
{p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null} {p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null}
</button> </button>
</li> </li>
@ -231,6 +241,17 @@ export default function AdminAiPromptsPage() {
onChange={(e) => setDraftDesc(e.target.value)} onChange={(e) => setDraftDesc(e.target.value)}
/> />
</div> </div>
<div className="form-row">
<label className="form-label">OpenRouter-Modell (optional)</label>
<input
className="form-input"
placeholder="Leer = Server-OPENROUTER_MODEL · z.B. anthropic/claude-3.5-haiku"
autoComplete="off"
spellCheck={false}
value={draftOpenrouterModel}
onChange={(e) => setDraftOpenrouterModel(e.target.value)}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} /> <input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} />
Aktiv Aktiv