Models like DeepSeek V4 Flash reason by default, and the reasoning budget
setting could only ever add thinking tokens - there was no value that turned
thinking off. A negative budget now sends `reasoning: {effort: "none"}`.
Uses effort:none rather than exclude:true deliberately - exclude still thinks
and still bills, it only hides the trace.
Zero keeps its old meaning (send no `reasoning` field at all) so endpoints that
reject unknown fields, like the default Ollama one, are unaffected. Reusing the
existing int column this way avoids a migration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
387 lines
11 KiB
Python
387 lines
11 KiB
Python
from datetime import datetime
|
|
from typing import Annotated, Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
|
|
from . import images
|
|
|
|
# Length caps (Phase 9). The VARCHAR ones are correctness, not just abuse
|
|
# limits: Postgres enforces column lengths (SQLite never did), so anything
|
|
# longer must be a 422 here rather than a 500 at INSERT. Text-column caps are
|
|
# generous abuse ceilings a legitimate player won't hit.
|
|
NAME_MAX = 200 # titles/names — VARCHAR(200)
|
|
TAGS_MAX = 500 # VARCHAR(500)
|
|
CARD_TYPE_MAX = 100 # VARCHAR(100)
|
|
PROSE_MAX = 50_000 # memory, author's note, prompts, entries, notes...
|
|
SCRIPT_MAX = 200_000 # one JS source
|
|
ACTION_MAX = 20_000 # one player action
|
|
MEMORY_TEXT_MAX = 5_000
|
|
# A scenario cover image, stored inline as a base64 data URI. 400x300 WebP at
|
|
# the quality the editor encodes lands around 20-40 KB; 400 KB leaves room for
|
|
# a client that downscales less aggressively without letting anyone park a
|
|
# multi-megabyte PNG in a row that gets read on every list request.
|
|
IMAGE_MAX = 400_000
|
|
ICON_MAX = 16 # one emoji/glyph — VARCHAR(16)
|
|
|
|
Name = Annotated[str, Field(max_length=NAME_MAX)]
|
|
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
|
|
CardType = Annotated[str, Field(max_length=CARD_TYPE_MAX)]
|
|
Prose = Annotated[str, Field(max_length=PROSE_MAX)]
|
|
ScriptSource = Annotated[str, Field(max_length=SCRIPT_MAX)]
|
|
ActionText = Annotated[str, Field(max_length=ACTION_MAX)]
|
|
Image = Annotated[str, Field(max_length=IMAGE_MAX)]
|
|
Icon = Annotated[str, Field(max_length=ICON_MAX)]
|
|
|
|
|
|
class ORMModel(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
# ---------- Story cards ----------
|
|
|
|
class StoryCardBase(BaseModel):
|
|
type: CardType = ""
|
|
name: Name = ""
|
|
keys: Prose = ""
|
|
entry: Prose = ""
|
|
notes: Prose = ""
|
|
|
|
|
|
class StoryCardCreate(StoryCardBase):
|
|
scenario_id: int | None = None
|
|
adventure_id: int | None = None
|
|
|
|
|
|
class StoryCardUpdate(BaseModel):
|
|
type: CardType | None = None
|
|
name: Name | None = None
|
|
keys: Prose | None = None
|
|
entry: Prose | None = None
|
|
notes: Prose | None = None
|
|
|
|
|
|
class StoryCardOut(ORMModel, StoryCardBase):
|
|
id: int
|
|
scenario_id: int | None
|
|
adventure_id: int | None
|
|
|
|
|
|
# ---------- Scenarios ----------
|
|
|
|
class ScenarioBase(BaseModel):
|
|
title: Name = "Untitled Scenario"
|
|
description: Prose = ""
|
|
prompt: Prose = ""
|
|
memory: Prose = ""
|
|
authors_note: Prose = ""
|
|
ai_instructions: Prose = ""
|
|
tags: Tags = ""
|
|
# Cover art — an https URL or a base64 data URI. See app/images.py.
|
|
image: Image = ""
|
|
# Emoji/glyph shown when `image` is empty.
|
|
icon: Icon = ""
|
|
# Phase 12: RPG world-state template (stat defs, bands, rules, milestones).
|
|
# None means no RPG layer.
|
|
stat_schema: dict | None = None
|
|
|
|
|
|
class ScenarioCreate(ScenarioBase):
|
|
pass
|
|
|
|
|
|
class ScenarioUpdate(BaseModel):
|
|
title: Name | None = None
|
|
description: Prose | None = None
|
|
prompt: Prose | None = None
|
|
memory: Prose | None = None
|
|
authors_note: Prose | None = None
|
|
ai_instructions: Prose | None = None
|
|
tags: Tags | None = None
|
|
image: Image | None = None
|
|
icon: Icon | None = None
|
|
stat_schema: dict | None = None
|
|
script_ids: list[int] | None = None
|
|
|
|
|
|
class ScenarioOut(ORMModel, ScenarioBase):
|
|
id: int
|
|
is_public: bool = False # shared demo content — read-only for everyone
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
story_cards: list[StoryCardOut] = []
|
|
scripts: list["ScriptOut"] = []
|
|
|
|
|
|
class ScenarioListItem(ORMModel):
|
|
id: int
|
|
title: str
|
|
description: str
|
|
tags: str
|
|
is_public: bool = False
|
|
updated_at: datetime
|
|
# Read off the row so `image_url` can be derived, but excluded from the
|
|
# response: a list of base64 data URIs would be megabytes of JSON.
|
|
image: str = Field("", exclude=True)
|
|
icon: str = ""
|
|
|
|
@computed_field
|
|
@property
|
|
def image_url(self) -> str:
|
|
return images.public_url(self.id, self.image, self.updated_at)
|
|
|
|
|
|
# ---------- Adventures ----------
|
|
|
|
class AdventureCreate(BaseModel):
|
|
scenario_id: int | None = None
|
|
title: Name | None = None
|
|
# ${Placeholder} values collected from the player at start (AI Dungeon behavior).
|
|
placeholders: dict[str, str] = {}
|
|
|
|
|
|
class AdventureUpdate(BaseModel):
|
|
title: Name | None = None
|
|
memory: Prose | None = None
|
|
authors_note: Prose | None = None
|
|
ai_instructions: Prose | None = None
|
|
story_summary: Prose | None = None
|
|
auto_summarize: bool | None = None
|
|
memory_bank_enabled: bool | None = None
|
|
|
|
|
|
class AdventureRefresh(BaseModel):
|
|
"""Body for "Update from scenario". `placeholders` supplies answers the
|
|
adventure has no stored value for (see AdventureCreate.placeholders); they
|
|
are merged over the stored ones and saved."""
|
|
|
|
placeholders: dict[str, str] = {}
|
|
|
|
|
|
class RefreshPlan(BaseModel):
|
|
"""What a refresh would change — drives the confirm dialog."""
|
|
|
|
scenario_id: int
|
|
scenario_title: str
|
|
has_changes: bool
|
|
# field name -> {"old": ..., "new": ...}, only for fields that differ.
|
|
fields: dict[str, dict] = {}
|
|
# {"added"|"updated"|"removed": [card name, ...]}
|
|
cards: dict[str, list[str]] = {}
|
|
# {"added"|"removed": [stat path, ...]} — live values are otherwise kept.
|
|
world_state: dict[str, list[str]] = {}
|
|
# ${Placeholder} names the scenario asks for that the adventure has no
|
|
# stored answer to; the client must collect these and send them back.
|
|
placeholders_needed: list[str] = []
|
|
|
|
|
|
class ActionOut(ORMModel):
|
|
id: int
|
|
adventure_id: int
|
|
index: int
|
|
type: str
|
|
text: str
|
|
reasoning: str | None = None
|
|
# Phase 12: compact RPG state changes for this turn (from the model property).
|
|
world_changes: list[dict] = []
|
|
created_at: datetime
|
|
|
|
|
|
class ActionUpdate(BaseModel):
|
|
text: ActionText
|
|
|
|
|
|
class ActionCreate(BaseModel):
|
|
type: Literal["do", "say", "story", "continue"]
|
|
text: ActionText = ""
|
|
|
|
|
|
class AdventureOut(ORMModel):
|
|
id: int
|
|
scenario_id: int | None
|
|
title: str
|
|
memory: str
|
|
authors_note: str
|
|
ai_instructions: str
|
|
story_summary: str
|
|
auto_summarize: bool
|
|
memory_bank_enabled: bool
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
story_cards: list[StoryCardOut] = []
|
|
actions: list[ActionOut] = []
|
|
|
|
|
|
# ---------- Memory bank (Phase 6) ----------
|
|
|
|
class MemoryOut(ORMModel):
|
|
id: int
|
|
adventure_id: int
|
|
text: str
|
|
pinned: bool
|
|
forgotten: bool
|
|
embedded: bool # model property: embedding vector present
|
|
use_count: int
|
|
last_used_at: datetime | None
|
|
source_start: int | None
|
|
source_end: int | None
|
|
created_at: datetime
|
|
|
|
|
|
class MemoryCreate(BaseModel):
|
|
text: Annotated[str, Field(max_length=MEMORY_TEXT_MAX)]
|
|
|
|
|
|
class MemoryUpdate(BaseModel):
|
|
text: Annotated[str, Field(max_length=MEMORY_TEXT_MAX)] | None = None
|
|
pinned: bool | None = None
|
|
forgotten: bool | None = None
|
|
|
|
|
|
class AdventureListItem(ORMModel):
|
|
id: int
|
|
scenario_id: int | None
|
|
scenario_title: str | None = None
|
|
title: str
|
|
updated_at: datetime
|
|
action_count: int = 0
|
|
# "Where you left off" — the tail of the most recent narrative beat, so a
|
|
# Continue card can show the story instead of just a turn count.
|
|
snippet: str = ""
|
|
# Cover art inherited from the parent scenario (see app/images.py).
|
|
image_url: str = ""
|
|
icon: str = ""
|
|
|
|
|
|
# ---------- Scripts ----------
|
|
|
|
class ScriptBase(BaseModel):
|
|
name: Name = "Untitled Script"
|
|
description: Prose = ""
|
|
library_js: ScriptSource = ""
|
|
input_js: ScriptSource = ""
|
|
context_js: ScriptSource = ""
|
|
output_js: ScriptSource = ""
|
|
|
|
|
|
class ScriptCreate(ScriptBase):
|
|
pass
|
|
|
|
|
|
class ScriptUpdate(BaseModel):
|
|
name: Name | None = None
|
|
description: Prose | None = None
|
|
library_js: ScriptSource | None = None
|
|
input_js: ScriptSource | None = None
|
|
context_js: ScriptSource | None = None
|
|
output_js: ScriptSource | None = None
|
|
|
|
|
|
class ScriptOut(ORMModel, ScriptBase):
|
|
id: int
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class ScriptTestRequest(BaseModel):
|
|
hook: Literal["input", "context", "output"]
|
|
text: Prose = ""
|
|
state: dict = {}
|
|
|
|
|
|
class AdventureScriptOut(ORMModel):
|
|
id: int
|
|
adventure_id: int
|
|
position: int
|
|
enabled: bool
|
|
name: str
|
|
description: str
|
|
library_js: str
|
|
input_js: str
|
|
context_js: str
|
|
output_js: str
|
|
# Set by the router (not stored): True when a syncable library version
|
|
# exists whose code differs from this copy; None when nothing to sync.
|
|
out_of_date: bool | None = None
|
|
|
|
|
|
class AdventureScriptUpdate(BaseModel):
|
|
enabled: bool | None = None
|
|
library_js: ScriptSource | None = None
|
|
input_js: ScriptSource | None = None
|
|
context_js: ScriptSource | None = None
|
|
output_js: ScriptSource | None = None
|
|
|
|
|
|
# ---------- Auth (Phase 8) ----------
|
|
|
|
class AuthCredentials(BaseModel):
|
|
email: Annotated[str, Field(max_length=320)] # VARCHAR(320)
|
|
# Upper bound keeps scrypt cost flat — hashing megabyte "passwords" is CPU
|
|
# an attacker would otherwise get for free.
|
|
password: Annotated[str, Field(max_length=128)]
|
|
|
|
|
|
# ---------- Settings ----------
|
|
|
|
class SettingsOut(ORMModel):
|
|
endpoint_url: str
|
|
# The key itself is never echoed back (encrypted at rest, write-only).
|
|
has_api_key: bool
|
|
model: str
|
|
api_mode: str
|
|
temperature: float
|
|
max_output_tokens: int
|
|
reasoning_max_tokens: int
|
|
context_token_budget: int
|
|
narrator_prompt: str
|
|
stream: bool
|
|
summary_model: str
|
|
embedding_model: str
|
|
memory_bank_capacity: int
|
|
memory_top_k: int
|
|
|
|
|
|
ScenarioOut.model_rebuild()
|
|
|
|
|
|
# ---------- AI Chat (power users) ----------
|
|
# A scratchpad for talking to a model directly, with no story framing. Nothing
|
|
# is persisted server-side, so these caps are purely per-request abuse limits.
|
|
|
|
CHAT_MESSAGE_MAX = 100_000 # one message
|
|
CHAT_TOTAL_MAX = 400_000 # whole conversation sent up per request
|
|
CHAT_MESSAGES_MAX = 200 # turns per request
|
|
|
|
|
|
class ChatMessage(BaseModel):
|
|
role: Literal["system", "user", "assistant"]
|
|
content: Annotated[str, Field(max_length=CHAT_MESSAGE_MAX)]
|
|
|
|
|
|
class ChatRequest(BaseModel):
|
|
messages: Annotated[list[ChatMessage], Field(min_length=1, max_length=CHAT_MESSAGES_MAX)]
|
|
# Empty/omitted = fall back to the user's configured model.
|
|
model: Name | None = None
|
|
temperature: Annotated[float, Field(ge=0, le=5)] | None = None
|
|
max_tokens: Annotated[int, Field(ge=1, le=100_000)] | None = None
|
|
|
|
|
|
class SettingsUpdate(BaseModel):
|
|
endpoint_url: Annotated[str, Field(max_length=500)] | None = None # VARCHAR(500)
|
|
# Encryption expands the stored value ~4/3 into the same VARCHAR(500):
|
|
# 256 plaintext chars is the largest safe input ("enc:" + Fernet + base64).
|
|
api_key: Annotated[str, Field(max_length=256)] | None = None
|
|
model: Name | None = None
|
|
api_mode: Annotated[str, Field(max_length=20)] | None = None
|
|
temperature: Annotated[float, Field(ge=0, le=5)] | None = None
|
|
max_output_tokens: Annotated[int, Field(ge=1, le=100_000)] | None = None
|
|
# -1 = explicitly off (sends `reasoning: {effort: none}`); 0 = send nothing.
|
|
reasoning_max_tokens: Annotated[int, Field(ge=-1, le=100_000)] | None = None
|
|
context_token_budget: Annotated[int, Field(ge=256, le=200_000)] | None = None
|
|
narrator_prompt: Prose | None = None
|
|
stream: bool | None = None
|
|
summary_model: Name | None = None
|
|
embedding_model: Name | None = None
|
|
memory_bank_capacity: Annotated[int, Field(ge=1, le=1000)] | None = None
|
|
memory_top_k: Annotated[int, Field(ge=1, le=50)] | None = None
|