Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2332024
feat: add multi-provider LLM support with litellm
claude Nov 18, 2025
6fdc96b
refactor: focus on model choice instead of provider choice
claude Nov 18, 2025
449cf41
fix: use only real models that actually exist
claude Nov 18, 2025
236dcdb
fix: add GPT-5 and Gemini 3 Pro Preview (the actual latest models)
claude Nov 18, 2025
12b8601
fix: imports
ccmdi Nov 18, 2025
3865296
feat: allow setting model via human-friendly names
claude Nov 18, 2025
60463b7
docs: add example of setting model in README
claude Nov 18, 2025
abdd9c9
Merge branch 'claude/codebase-review-01KknsD8mSzzuNTdeo2RhVaa' of htt…
ccmdi Nov 18, 2025
23402ea
fix: lazy load cmds
ccmdi Nov 18, 2025
5438b6b
refactor: simplify to single MODEL config (remove ai_provider/ai_model)
claude Nov 18, 2025
cda3cec
Merge remote-tracking branch 'origin/claude/codebase-review-01KknsD8m…
ccmdi Nov 18, 2025
3da06c7
refactor: avoid ai model redundancy
ccmdi Nov 18, 2025
68b9c4a
feat: centralize litellm logic
ccmdi Nov 18, 2025
b6db331
feat: setup dropdown for model selector
ccmdi Nov 18, 2025
897d663
chore: model name fix
ccmdi Nov 18, 2025
47db856
fix: add questionary mocking to setup tests and fix model_choice bug
claude Nov 19, 2025
c5bf4a6
fix: reload command modules in mock_services to fix test mocking
claude Nov 19, 2025
fc78d2c
fix: move service import to function definition for commands
ccmdi Nov 19, 2025
42b5d89
test: add multi-provider feature test coverage
claude Nov 19, 2025
36e94e6
fix: standardize tool_choice format for different LLM providers
claude Nov 19, 2025
27b6c69
fix: tool choice unification
ccmdi Nov 19, 2025
1acaf44
fix: gemini flash 2.5 model signature
ccmdi Nov 19, 2025
a1282e1
fix: add proper type annotations to ai/client.py
claude Nov 19, 2025
57248f6
.
ccmdi Nov 19, 2025
d9a6840
fix: use cast() to handle ModelResponse vs CustomStreamWrapper typing
claude Nov 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ This will start the interactive setup. Here's what you'll need:
- Install [plugin](https://github.com/coddingtonbear/obsidian-local-rest-api) in Obsidian
- Copy the API key from plugin settings

2. **Anthropic API key:**
- Get from [console.anthropic.com](https://console.anthropic.com/)
2. **AI Model:**
- Choose from popular models (Claude 4, GPT-4o, Gemini 2.0, etc.)
- 8+ providers supported through LiteLLM

3. **AnkiConnect setup:**
- Add-on code: `2055492159`
- Keep Anki running

You can then follow the interactive setup and edit the configuration as you like.
The interactive setup will guide you through model selection and configuration.

## Usage

Expand All @@ -49,9 +50,10 @@ oki # Alias

### Configuration
```bash
oki config # Show config
oki config get max_cards # Get specific setting
oki config set max_cards 15 # Update setting
oki config # Show config
oki config get max_cards # Get specific setting
oki config set max_cards 15 # Update setting
oki config set model "GPT-5" # Switch AI model
```

### Tags
Expand Down
583 changes: 287 additions & 296 deletions obsidianki/ai/client.py

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions obsidianki/ai/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
MODEL_MAP = {
"Claude Sonnet 4.5": {
"provider": "anthropic",
"url": "https://console.anthropic.com/",
"model": "claude-sonnet-4-5",
"key_name": "ANTHROPIC_API_KEY"
},
"Claude Opus 4": {
"provider": "anthropic",
"model": "claude-opus-4-1",
"key_name": "ANTHROPIC_API_KEY",
"url": "https://console.anthropic.com/"
},
"GPT-5": {
"provider": "openai",
"model": "gpt-5",
"key_name": "OPENAI_API_KEY",
"url": "https://platform.openai.com/api-keys"
},
"Gemini 3": {
"provider": "google",
"model": "gemini/gemini-2.5-pro",
"key_name": "GEMINI_API_KEY",
"url": "https://makersuite.google.com/app/apikey"
},
"GPT-4o": {
"provider": "openai",
"model": "gpt-4o",
"key_name": "OPENAI_API_KEY",
"url": "https://platform.openai.com/api-keys"
},
"GPT-4o Mini": {
"provider": "openai",
"model": "gpt-4o-mini",
"key_name": "OPENAI_API_KEY",
"url": "https://platform.openai.com/api-keys"
},
"Gemini 2.5 Flash": {
"provider": "google",
"model": "gemini/gemini-2.5-flash",
"key_name": "GEMINI_API_KEY",
"url": "https://makersuite.google.com/app/apikey"
},
"DeepSeek V3.1": {
"provider": "deepseek",
"model": "deepseek/deepseek-chat",
"key_name": "DEEPSEEK_API_KEY",
"url": "https://console.deepseek.com/"
}
}
121 changes: 64 additions & 57 deletions obsidianki/ai/tools.py
Original file line number Diff line number Diff line change
@@ -1,71 +1,78 @@
from anthropic.types import ToolParam

FLASHCARD_TOOL: ToolParam = {
"name": "create_flashcards",
"description": "Create flashcards from note content with front (question) and back (answer)",
"input_schema": {
"type": "object",
"properties": {
"flashcards": {
"type": "array",
"description": "Array of flashcards extracted from the note",
"items": {
"type": "object",
"properties": {
"front": {
"type": "string",
"description": "The question or prompt for the flashcard"
FLASHCARD_TOOL: dict = {
"type": "function",
"function": {
"name": "create_flashcards",
"description": "Create flashcards from note content with front (question) and back (answer)",
"parameters": {
"type": "object",
"properties": {
"flashcards": {
"type": "array",
"description": "Array of flashcards extracted from the note",
"items": {
"type": "object",
"properties": {
"front": {
"type": "string",
"description": "The question or prompt for the flashcard"
},
"back": {
"type": "string",
"description": "The answer or information for the flashcard"
}
},
"back": {
"type": "string",
"description": "The answer or information for the flashcard"
}
},
"required": ["front", "back"]
"required": ["front", "back"]
}
}
}
},
"required": ["flashcards"]
},
"required": ["flashcards"]
}
}
}

# DQL Execution Tool for multi-turn agent
DQL_EXECUTION_TOOL: ToolParam = {
"name": "execute_dql_query",
"description": "Execute a DQL query against the Obsidian vault and get results",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The DQL query to execute"
DQL_EXECUTION_TOOL: dict = {
"type": "function",
"function": {
"name": "execute_dql_query",
"description": "Execute a DQL query against the Obsidian vault and get results",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The DQL query to execute"
},
"reasoning": {
"type": "string",
"description": "Brief explanation of what this query is trying to find"
}
},
"reasoning": {
"type": "string",
"description": "Brief explanation of what this query is trying to find"
}
},
"required": ["query", "reasoning"]
"required": ["query", "reasoning"]
}
}
}

# Final selection tool for multi-turn agent
FINALIZE_SELECTION_TOOL: ToolParam = {
"name": "finalize_note_selection",
"description": "Finalize the selection of notes that best match the user's request",
"input_schema": {
"type": "object",
"properties": {
"selected_paths": {
"type": "array",
"items": {"type": "string"},
"description": "Array of note paths to process for flashcard generation"
FINALIZE_SELECTION_TOOL: dict = {
"type": "function",
"function": {
"name": "finalize_note_selection",
"description": "Finalize the selection of notes that best match the user's request",
"parameters": {
"type": "object",
"properties": {
"selected_paths": {
"type": "array",
"items": {"type": "string"},
"description": "Array of note paths to process for flashcard generation"
},
"reasoning": {
"type": "string",
"description": "Brief explanation of why these notes were selected"
}
},
"reasoning": {
"type": "string",
"description": "Brief explanation of why these notes were selected"
}
},
"required": ["selected_paths", "reasoning"]
"required": ["selected_paths", "reasoning"]
}
}
}
25 changes: 23 additions & 2 deletions obsidianki/cli/commands/config_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def handle_config_command(args):
"config": "List all configuration settings",
"config get <key>": "Get a configuration value",
"config set <key> <value>": "Set a configuration value",
"config set model \"<name>\"": "Set model",
"config reset": "Reset configuration to defaults",
"config where": "Show configuration directory path"
})
Expand Down Expand Up @@ -68,10 +69,30 @@ def handle_config_command(args):
from obsidianki.cli.config import DEFAULT_CONFIG

key_upper = args.key.upper()

# Special handling for "model" - allows human-friendly names
if key_upper == 'MODEL':
from obsidianki.ai.models import MODEL_MAP

if args.value in MODEL_MAP:
user_config["MODEL"] = args.value

with open(CONFIG_FILE, 'w') as f:
json.dump(user_config, f, indent=2)

console.print(f"[green]✓[/green] Set model to [bold]{args.value}[/bold]")
return
else:
console.print(f"[red]Invalid model: {args.value}[/red]")
console.print("[dim]Valid options:[/dim]")
for model_name in MODEL_MAP.keys():
console.print(f" - {model_name}")
return

# Check if key exists in DEFAULT_CONFIG (support new config keys)
if key_upper not in DEFAULT_CONFIG:
console.print(f"[red]Configuration key '{args.key}' not found.[/red]")
console.print("[dim]Use 'oki config list' to see available keys.[/dim]")
console.print("[dim]Use 'oki config' to see available keys.[/dim]")
return

# Try to convert value to appropriate type
Expand All @@ -83,7 +104,7 @@ def handle_config_command(args):
if key_upper == 'DIFFICULTY':
if value not in ('easy', 'normal', 'hard', 'none'):
console.print(f"[red]Invalid difficulty: {value}[/red]")
console.print("[dim]Valid options: easy, normal, hard[/dim]")
console.print("[dim]Valid options: easy, normal, hard, none[/dim]")
return

if isinstance(current_value, bool):
Expand Down
16 changes: 7 additions & 9 deletions obsidianki/cli/commands/deck_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
from rich.markup import escape
from rich.panel import Panel

from obsidianki.cli.services import ANKI
from obsidianki.cli.config import console
from obsidianki.cli.utils import strip_html
from obsidianki.cli.help_utils import show_simple_help


def handle_deck_command(args):
"""Handle deck management commands"""
from obsidianki.cli.services import ANKI

# Handle help request
if args.help:
Expand All @@ -23,17 +23,15 @@ def handle_deck_command(args):
})
return

anki = ANKI

# Test connection first
if not anki.test_connection():
if not ANKI.test_connection():
console.print("[red]ERROR:[/red] Cannot connect to AnkiConnect")
console.print("[dim]Make sure Anki is running with AnkiConnect add-on installed[/dim]")
return

if args.deck_action is None:
# Default action: list decks
deck_names = anki.get_decks()
deck_names = ANKI.get_decks()

if not deck_names:
console.print("[yellow]No decks found[/yellow]")
Expand All @@ -49,7 +47,7 @@ def handle_deck_command(args):
console.print(f"[dim]Found {len(deck_names)} decks:[/dim]")
console.print()
for deck_name in sorted(deck_names):
stats = anki.get_stats(deck_name)
stats = ANKI.get_stats(deck_name)
total_cards = stats.get("total_cards", 0)

console.print(f" [cyan]{deck_name}[/cyan]")
Expand All @@ -69,7 +67,7 @@ def handle_deck_command(args):

console.print(f"[cyan]Renaming deck:[/cyan] [bold]{old_name}[/bold] → [bold]{new_name}[/bold]")

if anki.rename_deck(old_name, new_name):
if ANKI.rename_deck(old_name, new_name):
console.print(f"[green]✓[/green] Successfully renamed deck to '[cyan]{new_name}[/cyan]'")
else:
console.print("[red]Failed to rename deck[/red]")
Expand All @@ -82,7 +80,7 @@ def handle_deck_command(args):
limit = args.limit

# Check if deck exists
deck_names = anki.get_decks()
deck_names = ANKI.get_decks()
if deck_name not in deck_names:
console.print(f"[red]ERROR:[/red] Deck '[cyan]{deck_name}[/cyan]' not found")
console.print("\n[dim]Available decks:[/dim]")
Expand All @@ -95,7 +93,7 @@ def handle_deck_command(args):
console.print()

# Search for cards
results = anki.search_cards(deck_name, query, limit)
results = ANKI.search_cards(deck_name, query, limit)

if not results:
console.print(f"[yellow]No cards found matching '{query}'[/yellow]")
Expand Down
3 changes: 2 additions & 1 deletion obsidianki/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"SYNTAX_HIGHLIGHTING": True, # Enable syntax highlighting for code blocks in flashcards
"UPFRONT_BATCHING": False, # Process all notes in parallel instead of one-by-one
"BATCH_SIZE_LIMIT": 20, # Maximum notes to process in batch mode
"BATCH_CARD_LIMIT": 100 # Maximum total cards in batch mode
"BATCH_CARD_LIMIT": 100, # Maximum total cards in batch mode
"MODEL": "Claude Sonnet 4.5" # AI model to use (Claude Sonnet 4, GPT-5, Gemini 3 Pro Preview, etc.)
}

class Config:
Expand Down
3 changes: 2 additions & 1 deletion obsidianki/cli/interactive/edit_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from rich.prompt import Prompt

from obsidianki.cli.models import Note, Flashcard
from obsidianki.cli.services import ANKI, AI
from obsidianki.cli.config import CONFIG, console
from obsidianki.cli.interactive.approval import approve_flashcard
from obsidianki.cli.interactive.card_selector import create_card_selector
Expand All @@ -14,6 +13,8 @@ def edit_mode(args):
"""
Entry point for interactive editing of existing flashcards.
"""
from obsidianki.cli.services import ANKI, AI

deck_name = args.deck if args.deck else CONFIG.deck

console.print(Panel("ObsidianKi - Editing mode", style="bold blue"))
Expand Down
Loading